@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
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
* Which endpoint member each knob governs, by NAME only.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately strings rather than function references: this file must stay a leaf that imports
|
|
43
|
+
* nothing but enums and interfaces (see the header), and both consumers need the same list —
|
|
44
|
+
* `applyFileRoles()` writes metadata onto these members, and the boot audit reads it back off
|
|
45
|
+
* whichever class the project actually registered. Two hand-maintained copies of that list is
|
|
46
|
+
* exactly how one of them ends up governing a member the other forgot.
|
|
47
|
+
*
|
|
48
|
+
* `getFileInfo` rides with `downloadRoles` rather than getting its own knob: it answers filename,
|
|
49
|
+
* size and content type for a blob, which is the metadata half of a download. Splitting it would let
|
|
50
|
+
* a project accidentally publish the bucket's contents list while believing downloads were closed.
|
|
51
|
+
*/
|
|
52
|
+
export const FILE_ROLE_MEMBERS: { className: FileEndpointClassName; key: FileRoleKey; method: string }[] = [
|
|
53
|
+
{ className: 'CoreFileController', key: 'downloadRoles', method: 'getFileById' },
|
|
54
|
+
{ className: 'CoreFileController', key: 'downloadRoles', method: 'getFile' },
|
|
55
|
+
{ className: 'CoreFileResolver', key: 'downloadRoles', method: 'getFileInfo' },
|
|
56
|
+
{ className: 'CoreFileResolver', key: 'uploadRoles', method: 'uploadFile' },
|
|
57
|
+
{ className: 'CoreFileResolver', key: 'uploadRoles', method: 'uploadFiles' },
|
|
58
|
+
{ className: 'CoreFileResolver', key: 'deleteRoles', method: 'deleteFile' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** The two core endpoint classes the role knobs govern. */
|
|
62
|
+
export type FileEndpointClassName = 'CoreFileController' | 'CoreFileResolver';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve one knob to the role list that will actually be applied.
|
|
66
|
+
*
|
|
67
|
+
* An empty array is treated as "not configured". It cannot mean "nobody": the
|
|
68
|
+
* guards read an all-empty role set as "no roles required" and return true, so
|
|
69
|
+
* honouring it literally would OPEN the route instead of closing it — the exact
|
|
70
|
+
* opposite of what someone writing `[]` intends.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
|
|
73
|
+
const configured = config?.[key];
|
|
74
|
+
|
|
75
|
+
if (configured === undefined) {
|
|
76
|
+
return FILE_ROLE_DEFAULTS[key];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!Array.isArray(configured) || configured.length === 0 || configured.some((role) => typeof role !== 'string')) {
|
|
80
|
+
logger.warn(
|
|
81
|
+
`Ignoring file.${key}: expected a non-empty array of role strings, got ${JSON.stringify(configured)}. ` +
|
|
82
|
+
`Falling back to ${JSON.stringify(FILE_ROLE_DEFAULTS[key])}.`,
|
|
83
|
+
);
|
|
84
|
+
return FILE_ROLE_DEFAULTS[key];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return configured;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Did somebody DECLARE the per-file policy?
|
|
92
|
+
*
|
|
93
|
+
* Shared by both file-access warnings on purpose. They must silence on exactly the same conditions,
|
|
94
|
+
* and two copies of that rule is how a third silencer gets added to one and forgotten in the other —
|
|
95
|
+
* at which point the boot audit starts firing on a project that did decide, gets muted, and protects
|
|
96
|
+
* nobody. Keeping it in one place makes that particular drift impossible rather than merely unlikely.
|
|
97
|
+
*
|
|
98
|
+
* `'custom'` does not count: it is the escape hatch that says "I will answer this in code", so the
|
|
99
|
+
* only evidence that somebody actually did is an overridden `checkRights()`.
|
|
100
|
+
*/
|
|
101
|
+
export function hasDeclaredFilePolicy(options: { fileConfig?: IFileConfig; hasPerFileRule: boolean }): boolean {
|
|
102
|
+
return options.hasPerFileRule || !!(options.fileConfig?.access && options.fileConfig.access !== 'custom');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Warn when presigned S3 downloads are combined with a restricted `downloadRoles`.
|
|
107
|
+
*
|
|
108
|
+
* The two settings pull in opposite directions, and the conflict is invisible at the call site:
|
|
109
|
+
*
|
|
110
|
+
* - `downloadRoles` says "only these roles may download this file". It is enforced on every request.
|
|
111
|
+
* - `presignedDownloads` answers `302` with a time-limited S3 URL instead of streaming. That URL is
|
|
112
|
+
* a BEARER capability — authorized once, at issue time. Whoever holds it afterwards fetches the
|
|
113
|
+
* object with no session, from any IP, until it expires, and **the grant cannot be revoked in
|
|
114
|
+
* between**. It survives in browser history, `Referer` headers, proxy logs and chat messages.
|
|
115
|
+
*
|
|
116
|
+
* So the second setting hands out exactly what the first one restricts. That is a sound trade for
|
|
117
|
+
* public assets (which is what presigning is FOR — hence no warning when downloads are
|
|
118
|
+
* `S_EVERYONE`), and almost never what a project means when it has narrowed the roles.
|
|
119
|
+
*
|
|
120
|
+
* A warning rather than a boot failure: unlike an incoherent role vocabulary, this combination has
|
|
121
|
+
* legitimate uses (short expiry, a CDN in front, files whose audience really is "anyone who once
|
|
122
|
+
* held the link"). The operator has to be able to choose it — they just should not choose it by
|
|
123
|
+
* accident.
|
|
124
|
+
*/
|
|
125
|
+
export function warnOnPresignedDownloadsWithRestrictedRoles(
|
|
126
|
+
s3Config?: IS3Config,
|
|
127
|
+
fileConfig?: IFileConfig,
|
|
128
|
+
): string | undefined {
|
|
129
|
+
const presigned = s3Config?.presignedDownloads;
|
|
130
|
+
const presignedEnabled =
|
|
131
|
+
presigned === true || (!!presigned && typeof presigned === 'object' && (presigned as any).enabled !== false);
|
|
132
|
+
if (!presignedEnabled) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const downloadRoles = resolveRoles('downloadRoles', fileConfig);
|
|
137
|
+
// Public downloads are the intended use of presigning — nothing to warn about.
|
|
138
|
+
if (downloadRoles.includes(RoleEnum.S_EVERYONE)) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Returned as well as logged: the message IS the contract here (an operator has to be able to act
|
|
143
|
+
// on it), and a module-private Logger instance cannot be asserted against from a unit test.
|
|
144
|
+
const message =
|
|
145
|
+
`s3.presignedDownloads is enabled while file.downloadRoles restricts downloads to ` +
|
|
146
|
+
`${JSON.stringify(downloadRoles)}. A presigned URL is a BEARER capability: it is authorized ` +
|
|
147
|
+
`once, at issue time, and afterwards anyone holding it can fetch the object with no session, ` +
|
|
148
|
+
`from any IP, until it expires — the grant cannot be revoked in between, and it survives in ` +
|
|
149
|
+
`browser history, Referer headers and proxy logs. The role check therefore applies to ` +
|
|
150
|
+
`obtaining the link, not to reading the file. If these files are sensitive (personal or ` +
|
|
151
|
+
`medical data), set s3.presignedDownloads: false so the API streams them and re-checks ` +
|
|
152
|
+
`rights on every request.`;
|
|
153
|
+
|
|
154
|
+
logger.warn(message);
|
|
155
|
+
return message;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Warn when the file gate is open and NOTHING decides the per-file policy.
|
|
160
|
+
*
|
|
161
|
+
* THE GAP: the role knobs are a coarse audience filter — they answer "may this caller reach the route
|
|
162
|
+
* at all". They cannot express "…but only their own", because that sentence needs data. So a deployment
|
|
163
|
+
* that widened the gate past platform admins and expressed no per-file policy anywhere has a store
|
|
164
|
+
* every holder of that role can read in full.
|
|
165
|
+
*
|
|
166
|
+
* And that is practically reachable, not theoretically: file ids are ENUMERABLE. An ObjectId is 4 bytes
|
|
167
|
+
* of timestamp + 5 bytes of randomness generated once PER PROCESS + a 3-byte incrementing counter, so a
|
|
168
|
+
* caller who obtains one valid id — their own upload — knows the random part and a counter reference
|
|
169
|
+
* point, and neighbouring files sit on neighbouring values. Nothing rate-limits the file routes either.
|
|
170
|
+
*
|
|
171
|
+
* WHY THE CONDITIONS ARE THIS NARROW — a warning that fires on a correct configuration gets muted, and
|
|
172
|
+
* a muted warning is worse than none. So every way of DECIDING silences it, and all three are
|
|
173
|
+
* legitimate:
|
|
174
|
+
*
|
|
175
|
+
* 1. `file.access` names a project class (`'public'`, `'authenticated'`, `'owner'`, `'tenant'`);
|
|
176
|
+
* 2. `checkRights()` is overridden — the project wrote its own rule, and grading it is beyond what a
|
|
177
|
+
* boot check can do;
|
|
178
|
+
* 3. the gate is still admin-only — a platform admin legitimately sees everything.
|
|
179
|
+
*
|
|
180
|
+
* That leaves exactly one case: the gate is open and nothing says what the policy is. This warning is
|
|
181
|
+
* about the difference between a DECISION and an OMISSION, which is the only thing a boot check can
|
|
182
|
+
* usefully detect.
|
|
183
|
+
*
|
|
184
|
+
* Multi-tenancy changes only the WORDING. An earlier version of this warning fired only for tenant
|
|
185
|
+
* projects, which was too narrow: `downloadRoles: [S_USER]` with no rule leaks every file to every
|
|
186
|
+
* signed-in user whether or not tenants exist. Where tenants DO exist, the leak also crosses that
|
|
187
|
+
* boundary, and that sentence has to appear — the file stores are reached outside Mongoose, so
|
|
188
|
+
* `mongooseTenantPlugin` never scopes them and these role names resolve against `user.roles`, a GLOBAL
|
|
189
|
+
* attribute.
|
|
190
|
+
*
|
|
191
|
+
* A warning, not a boot failure. The framework cannot know whether the files are patient documents or
|
|
192
|
+
* public logos, and refusing to start on a configuration that is correct for the second would be
|
|
193
|
+
* wrong. What it can do is refuse to be silent.
|
|
194
|
+
*
|
|
195
|
+
* SCOPE — this function reads CONFIGURATION, which is the right source for a member the project
|
|
196
|
+
* INHERITS: `applyFileRoles()` writes the configured roles onto the base-class function, the subclass
|
|
197
|
+
* picks them up through the prototype chain, and config and reality agree. It says nothing about a
|
|
198
|
+
* member the project RE-DECLARES, because an override is a different function carrying its own
|
|
199
|
+
* `@Roles()` and the configuration never reaches that route. That half is covered by
|
|
200
|
+
* {@link warnOnUndecidedEffectiveFileAccess}, driven from `CoreFileAccessAuditInitializer` at
|
|
201
|
+
* bootstrap — the earliest point at which the registered class exists. The two do not overlap: the
|
|
202
|
+
* audit reports only roles this function's source cannot account for.
|
|
203
|
+
*
|
|
204
|
+
* @param hasPerFileRule whether `CoreFileService.checkRights()` is overridden — the caller knows,
|
|
205
|
+
* because it has the instance; this helper stays a pure function so it can be unit-tested.
|
|
206
|
+
* @returns the message, or `undefined` when there is nothing to warn about. Returned as well as
|
|
207
|
+
* logged for the same reason as {@link warnOnPresignedDownloadsWithRestrictedRoles}: the message IS
|
|
208
|
+
* the contract, and a module-private Logger cannot be asserted against.
|
|
209
|
+
*/
|
|
210
|
+
export function warnOnUndecidedFileAccess(options: {
|
|
211
|
+
fileConfig?: IFileConfig;
|
|
212
|
+
hasPerFileRule: boolean;
|
|
213
|
+
multiTenancyEnabled: boolean;
|
|
214
|
+
}): string | undefined {
|
|
215
|
+
const { fileConfig, hasPerFileRule, multiTenancyEnabled } = options;
|
|
216
|
+
|
|
217
|
+
// (1) and (2): somebody decided.
|
|
218
|
+
if (hasDeclaredFilePolicy({ fileConfig, hasPerFileRule })) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// (3): admin-only. ADMIN is unioned in by the class-level decorator regardless, so it never widens.
|
|
223
|
+
const widened: string[] = [];
|
|
224
|
+
for (const key of ['deleteRoles', 'downloadRoles', 'uploadRoles'] as FileRoleKey[]) {
|
|
225
|
+
const roles = resolveRoles(key, fileConfig);
|
|
226
|
+
if (roles.some((role) => role !== RoleEnum.ADMIN)) {
|
|
227
|
+
widened.push(`file.${key}=${JSON.stringify(roles)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!widened.length) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const tenantNote = multiTenancyEnabled
|
|
235
|
+
? ' multiTenancy is active, and the leak crosses tenants too: the file stores are reached outside ' +
|
|
236
|
+
'Mongoose, so mongooseTenantPlugin never scopes them and these role names resolve against ' +
|
|
237
|
+
'user.roles — a GLOBAL attribute — never against membership.role.'
|
|
238
|
+
: '';
|
|
239
|
+
|
|
240
|
+
const message =
|
|
241
|
+
`The file gate is open beyond platform admins (${widened.join(', ')}), but no per-file policy is ` +
|
|
242
|
+
`declared: file.access is unset and CoreFileService.checkRights() is not overridden. Every holder ` +
|
|
243
|
+
`of such a role can therefore read, overwrite or delete EVERY file — and file ids are not secret, ` +
|
|
244
|
+
`they are ENUMERABLE (an ObjectId shares a per-process random part and an incrementing counter, so ` +
|
|
245
|
+
`one own upload reveals the neighbourhood), with no rate limit on the file routes.${tenantNote} ` +
|
|
246
|
+
`Declare the project class with file.access ('public' | 'authenticated' | 'owner' | 'tenant'), or ` +
|
|
247
|
+
`override checkRights() — see src/core/modules/file/README.md § Access control.`;
|
|
248
|
+
|
|
249
|
+
logger.warn(message);
|
|
250
|
+
return message;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One registered endpoint member, as the GUARDS will see it.
|
|
255
|
+
*
|
|
256
|
+
* `roles` is the union of handler-level and class-level metadata, because that is what
|
|
257
|
+
* `mergeRolesMetadata` computes — not the handler alone. A subclass that carries a class-level
|
|
258
|
+
* `@Roles(S_EVERYONE)` widens every member it declares, and reading only the handler would miss it.
|
|
259
|
+
*/
|
|
260
|
+
export interface ObservedFileHandler {
|
|
261
|
+
/** which knob governs this member */
|
|
262
|
+
key: FileRoleKey;
|
|
263
|
+
/** `'FileController.getFileById'` — named as REGISTERED, so the operator can go straight to it */
|
|
264
|
+
member: string;
|
|
265
|
+
/** the effective role union the guards will evaluate */
|
|
266
|
+
roles: string[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Warn when a REGISTERED file endpoint is open beyond platform admins for a reason the configuration
|
|
271
|
+
* does not explain — i.e. a `@Roles()` written in the project's own subclass.
|
|
272
|
+
*
|
|
273
|
+
* WHY THIS EXISTS SEPARATELY FROM {@link warnOnUndecidedFileAccess}. That one reads CONFIGURATION,
|
|
274
|
+
* which is the right source for the inherited case: `applyFileRoles()` writes the configured roles
|
|
275
|
+
* onto the base-class member, an inheriting subclass picks them up through the prototype chain, and
|
|
276
|
+
* config and reality agree. They stop agreeing the moment a project RE-DECLARES a member. Decorator
|
|
277
|
+
* metadata lives on the function object, so an override is a different function carrying its own
|
|
278
|
+
* roles — and that is the function Nest registers. The configuration never reaches the route.
|
|
279
|
+
*
|
|
280
|
+
* The consequence was a silence exactly where the noise was wanted: a subclassed `getFileById()` with
|
|
281
|
+
* `@Roles(RoleEnum.S_EVERYONE)` serves anonymous downloads while `resolveRoles('downloadRoles', …)`
|
|
282
|
+
* still answers `[ADMIN]`. Two independent consumer projects shipped that, and in both the actually
|
|
283
|
+
* open routes were the ones nothing reported. A warning that is quiet in the dangerous case and loud
|
|
284
|
+
* in the safe one is worse than no warning, because it reads as a clean bill of health.
|
|
285
|
+
*
|
|
286
|
+
* WHAT IT REPORTS — only roles the configuration does not account for:
|
|
287
|
+
*
|
|
288
|
+
* effective = union(handler roles, class roles) // what mergeRolesMetadata gives the guard
|
|
289
|
+
* unexplained = effective − {ADMIN} − configured(key)
|
|
290
|
+
*
|
|
291
|
+
* That subtraction is what keeps this from double-warning. When the widening came from
|
|
292
|
+
* `file.downloadRoles`, {@link warnOnUndecidedFileAccess} has already said so and `unexplained` is
|
|
293
|
+
* empty. When an override widens FURTHER than the configuration does, only the extra roles are
|
|
294
|
+
* named — the part that is genuinely invisible elsewhere.
|
|
295
|
+
*
|
|
296
|
+
* ADMIN is subtracted because both endpoint classes carry a class-level `@Roles(ADMIN)` that the
|
|
297
|
+
* guards union in unconditionally. It is present on every member and never widens anything.
|
|
298
|
+
*
|
|
299
|
+
* The silencers are deliberately identical to {@link warnOnUndecidedFileAccess} — an overridden
|
|
300
|
+
* `checkRights()` or a declared `file.access` means somebody decided, and how they decided is beyond
|
|
301
|
+
* what a boot check can grade. A warning that fires on a correct setup gets muted, and a muted
|
|
302
|
+
* warning protects nobody.
|
|
303
|
+
*
|
|
304
|
+
* @returns the message, or `undefined` when there is nothing to report. Returned as well as logged
|
|
305
|
+
* for the same reason as the other two warnings: the message IS the contract, and a module-private
|
|
306
|
+
* Logger cannot be asserted against from a unit test.
|
|
307
|
+
*/
|
|
308
|
+
export function warnOnUndecidedEffectiveFileAccess(options: {
|
|
309
|
+
fileConfig?: IFileConfig;
|
|
310
|
+
handlers: ObservedFileHandler[];
|
|
311
|
+
hasPerFileRule: boolean;
|
|
312
|
+
multiTenancyEnabled: boolean;
|
|
313
|
+
}): string | undefined {
|
|
314
|
+
const { fileConfig, handlers, hasPerFileRule, multiTenancyEnabled } = options;
|
|
315
|
+
|
|
316
|
+
// Somebody decided. The SAME predicate the configuration-side warning uses — see
|
|
317
|
+
// hasDeclaredFilePolicy() for why this must not be a second copy of the rule.
|
|
318
|
+
if (hasDeclaredFilePolicy({ fileConfig, hasPerFileRule })) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const findings: string[] = [];
|
|
323
|
+
for (const handler of handlers) {
|
|
324
|
+
const configured = resolveRoles(handler.key, fileConfig);
|
|
325
|
+
const unexplained = handler.roles.filter((role) => role !== RoleEnum.ADMIN && !configured.includes(role));
|
|
326
|
+
if (unexplained.length) {
|
|
327
|
+
findings.push(`${handler.member} → ${JSON.stringify([...new Set(unexplained)])}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!findings.length) {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const tenantNote = multiTenancyEnabled
|
|
336
|
+
? ' multiTenancy is active, and the leak crosses tenants too: the file stores are reached outside ' +
|
|
337
|
+
'Mongoose, so mongooseTenantPlugin never scopes them and these role names resolve against ' +
|
|
338
|
+
'user.roles — a GLOBAL attribute — never against membership.role.'
|
|
339
|
+
: '';
|
|
340
|
+
|
|
341
|
+
const message =
|
|
342
|
+
`A registered file endpoint is open beyond platform admins through roles declared in your own ` +
|
|
343
|
+
`class, not through configuration (${findings.join(', ')}), and no per-file policy is declared: ` +
|
|
344
|
+
`file.access is unset and CoreFileService.checkRights() is not overridden. Because the member is ` +
|
|
345
|
+
`RE-DECLARED, file.downloadRoles/uploadRoles/deleteRoles do NOT apply to it — decorator metadata ` +
|
|
346
|
+
`lives on the function object, so your override keeps its own @Roles() and the configuration ` +
|
|
347
|
+
`never reaches the route. Every holder of such a role can therefore read, overwrite or delete ` +
|
|
348
|
+
`EVERY file — and file ids are not secret, they are ENUMERABLE (an ObjectId shares a per-process ` +
|
|
349
|
+
`random part and an incrementing counter, so one own upload reveals the neighbourhood), with no ` +
|
|
350
|
+
`rate limit on the file routes.${tenantNote} Either inherit the member instead of re-declaring ` +
|
|
351
|
+
`it, so the knobs apply, or declare the per-file policy with file.access ` +
|
|
352
|
+
`('public' | 'authenticated' | 'owner' | 'tenant') or an overridden checkRights() — see ` +
|
|
353
|
+
`src/core/modules/file/README.md § Access control.`;
|
|
354
|
+
|
|
355
|
+
logger.warn(message);
|
|
356
|
+
return message;
|
|
357
|
+
}
|
|
@@ -1,72 +1,36 @@
|
|
|
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 { FILE_ROLE_MEMBERS, FileEndpointClassName, 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
|
-
|
|
21
|
-
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Which member is governed by which knob.
|
|
26
|
-
*
|
|
27
|
-
* `getFileInfo` rides with `downloadRoles` rather than getting its own knob:
|
|
28
|
-
* it answers filename, size and content type for a blob, which is the metadata
|
|
29
|
-
* half of a download. Splitting it would let a project accidentally publish the
|
|
30
|
-
* bucket's contents list while believing downloads were still closed.
|
|
31
|
-
*/
|
|
32
|
-
const ROLE_TARGETS: { key: FileRoleKey; member: string; owner: () => unknown }[] = [
|
|
33
|
-
{
|
|
34
|
-
key: 'downloadRoles',
|
|
35
|
-
member: 'CoreFileController.getFileById',
|
|
36
|
-
owner: () => CoreFileController.prototype.getFileById,
|
|
37
|
-
},
|
|
38
|
-
{ key: 'downloadRoles', member: 'CoreFileController.getFile', owner: () => CoreFileController.prototype.getFile },
|
|
39
|
-
{ key: 'downloadRoles', member: 'CoreFileResolver.getFileInfo', owner: () => CoreFileResolver.prototype.getFileInfo },
|
|
40
|
-
{ key: 'uploadRoles', member: 'CoreFileResolver.uploadFile', owner: () => CoreFileResolver.prototype.uploadFile },
|
|
41
|
-
{ key: 'uploadRoles', member: 'CoreFileResolver.uploadFiles', owner: () => CoreFileResolver.prototype.uploadFiles },
|
|
42
|
-
{ key: 'deleteRoles', member: 'CoreFileResolver.deleteFile', owner: () => CoreFileResolver.prototype.deleteFile },
|
|
43
|
-
];
|
|
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
|
+
FILE_ROLE_MEMBERS,
|
|
16
|
+
resolveRoles,
|
|
17
|
+
warnOnPresignedDownloadsWithRestrictedRoles,
|
|
18
|
+
warnOnUndecidedEffectiveFileAccess,
|
|
19
|
+
warnOnUndecidedFileAccess,
|
|
20
|
+
} from './file-roles.config';
|
|
21
|
+
export type { FileEndpointClassName, FileRoleKey, ObservedFileHandler } from './file-roles.config';
|
|
44
22
|
|
|
45
23
|
/**
|
|
46
|
-
*
|
|
24
|
+
* The prototypes the member names in {@link FILE_ROLE_MEMBERS} resolve against.
|
|
47
25
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* opposite of what someone writing `[]` intends.
|
|
26
|
+
* The NAMES live in `file-roles.config.ts` so the boot audit can share them without importing these
|
|
27
|
+
* classes (that import is what makes this file a non-leaf — see the header of `file-roles.config.ts`
|
|
28
|
+
* for the temporal-dead-zone crash it caused). Only the class lookup lives here.
|
|
52
29
|
*/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
}
|
|
30
|
+
const ENDPOINT_PROTOTYPES: Record<FileEndpointClassName, unknown> = {
|
|
31
|
+
CoreFileController: CoreFileController.prototype,
|
|
32
|
+
CoreFileResolver: CoreFileResolver.prototype,
|
|
33
|
+
};
|
|
70
34
|
|
|
71
35
|
/**
|
|
72
36
|
* Apply the configured file roles to the core file endpoints.
|
|
@@ -94,14 +58,14 @@ function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
|
|
|
94
58
|
export function applyFileRoles(config?: IFileConfig): void {
|
|
95
59
|
const resolved = new Map<FileRoleKey, string[]>();
|
|
96
60
|
|
|
97
|
-
for (const {
|
|
61
|
+
for (const { className, key, method } of FILE_ROLE_MEMBERS) {
|
|
98
62
|
if (!resolved.has(key)) {
|
|
99
63
|
resolved.set(key, resolveRoles(key, config));
|
|
100
64
|
}
|
|
101
65
|
|
|
102
|
-
const target =
|
|
66
|
+
const target = (ENDPOINT_PROTOTYPES[className] as Record<string, unknown>)[method];
|
|
103
67
|
if (typeof target !== 'function') {
|
|
104
|
-
logger.warn(`Cannot apply file.${key}: ${
|
|
68
|
+
logger.warn(`Cannot apply file.${key}: ${className}.${method} is not a function — skipping.`);
|
|
105
69
|
continue;
|
|
106
70
|
}
|
|
107
71
|
|
|
@@ -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 |
|