@lenne.tech/nest-server 11.33.0 → 11.33.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/FRAMEWORK-API.md +1 -1
- package/dist/config.env.js +6 -0
- package/dist/config.env.js.map +1 -1
- package/dist/core/modules/file/core-file.service.js +1 -1
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/server/modules/file/file.controller.js +2 -2
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +2 -2
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +5 -1
- package/dist/server/modules/file/file.service.js +11 -0
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.js +1 -1
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/migration-guides/11.32.x-to-11.33.x.md +37 -4
- package/package.json +1 -1
- package/src/config.env.ts +45 -0
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +16 -4
- package/src/core/modules/file/README.md +35 -4
- package/src/core/modules/file/core-file.service.ts +39 -5
- package/src/server/modules/file/file.controller.ts +7 -2
- package/src/server/modules/file/file.resolver.ts +6 -2
- package/src/server/modules/file/file.service.ts +73 -27
- package/src/server/modules/user/avatar.controller.ts +8 -1
|
@@ -162,7 +162,7 @@ JWT-based authentication for existing projects:
|
|
|
162
162
|
| **File Module** | Upload/download with MongoDB GridFS storage |
|
|
163
163
|
| **REST Endpoints** | `GET /files/id/:id`, `GET /files/:filename` (core, gated by `file.downloadRoles`, default ADMIN); `POST /files/upload`, `DELETE /files/:id` (project-specific) |
|
|
164
164
|
| **GraphQL Endpoints** | `getFileInfo` (`file.downloadRoles`), `uploadFile` / `uploadFiles` (`file.uploadRoles`), `deleteFile` (`file.deleteRoles`) — all default ADMIN |
|
|
165
|
-
| **File access control** | Roles are the coarse filter; per-file rules go in `CoreFileService.checkRights()` using metadata written at upload time. Both file classes carry `@SkipTenantCheck()` — GridFS is not tenant-scoped |
|
|
165
|
+
| **File access control** | Roles are the coarse filter; per-file rules go in `CoreFileService.checkRights()` using metadata written at upload time. Cover BOTH the `id` and the `filename` branch — the filename route authorizes on the by-name lookup alone when presigned S3 downloads are on, and `deleteFileByName()` always does. Working reference: `src/server/modules/file/file.service.ts` (with `file.downloadRoles: [S_USER]` in `src/config.env.ts`, so the rule is actually reached). Both file classes carry `@SkipTenantCheck()` — GridFS is not tenant-scoped |
|
|
166
166
|
| **TUS Module** | Resumable uploads via tus.io protocol (creation, termination, expiration), gated by `tus.roles` (default `S_USER`); `OPTIONS` stays public for the CORS preflight |
|
|
167
167
|
| **GridFS Migration** | Completed TUS uploads auto-migrate to GridFS |
|
|
168
168
|
| **CORS Support** | Automatic CORS headers for browser uploads |
|
|
@@ -207,18 +207,51 @@ export class FileService extends CoreFileService {
|
|
|
207
207
|
input: any,
|
|
208
208
|
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
209
209
|
): Promise<boolean> {
|
|
210
|
-
|
|
210
|
+
// Writes, list queries and forced (system) calls stay on the coarse role gate
|
|
211
|
+
if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
211
212
|
return true;
|
|
212
213
|
}
|
|
213
|
-
if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
|
|
214
|
+
if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
214
215
|
return true;
|
|
215
216
|
}
|
|
216
|
-
const raw =
|
|
217
|
-
|
|
217
|
+
const raw = options.checkInputType === 'id'
|
|
218
|
+
? await this.getRawFileInfo(input)
|
|
219
|
+
: await this.getRawFileInfoByName(input);
|
|
220
|
+
// Fails closed without a user, and on a file that records no owner.
|
|
221
|
+
return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
218
222
|
}
|
|
219
223
|
}
|
|
220
224
|
```
|
|
221
225
|
|
|
226
|
+
**Cover the `filename` branch, not only `id`.** An id-only rule is enough while bytes are streamed —
|
|
227
|
+
the filename route resolves an id and checks it again — but not once `s3.presignedDownloads` is on
|
|
228
|
+
(the filename route then authorizes on the by-name lookup alone and redirects), and not for
|
|
229
|
+
`deleteFileByName()`, which authorizes by name only. This is not hypothetical: 11.33.0 shipped a
|
|
230
|
+
`deleteFileByName()` that authorized the caller and then re-resolved the file with an EMPTY context,
|
|
231
|
+
so an overridden `checkRights()` was asked two different questions about one request and the owner
|
|
232
|
+
got `File not found` for their own file. Both halves — the by-name branch and the forwarding — are
|
|
233
|
+
covered by the reference server as of the next release.
|
|
234
|
+
|
|
235
|
+
**Do not add an `if (!options.currentUser) return true` branch.** It reads as "system-internal call,
|
|
236
|
+
the guard already decided", and that is not what it means: "no user in context" is also exactly what
|
|
237
|
+
an ANONYMOUS request looks like. While `downloadRoles` is narrower than `S_EVERYONE` the coarse gate
|
|
238
|
+
rejects those first and the branch looks harmless — widen the gate, which §A.2 explicitly offers, and
|
|
239
|
+
the same branch hands every file to everyone. The ownership rule then evaporates precisely when it
|
|
240
|
+
starts to matter. For the same reason the final comparison requires `metadata.ownerId` to be
|
|
241
|
+
**present**: `String(undefined) === String(undefined)` would match an owner-less file against an
|
|
242
|
+
absent user.
|
|
243
|
+
|
|
244
|
+
Callers that genuinely are internal should say so, rather than relying on the omission:
|
|
245
|
+
`{ force: true }` where a role decorator has already decided (an `@Roles(ADMIN)` admin endpoint), or
|
|
246
|
+
the real `{ currentUser }` where the user is in scope (a controller cleaning up a file it just
|
|
247
|
+
replaced — that delete should be COVERED by the ownership rule, not exempt from it). The reference
|
|
248
|
+
server does both; see `src/server/modules/file/` and `src/server/modules/user/avatar.controller.ts`.
|
|
249
|
+
|
|
250
|
+
The reference server also **runs** the rule above rather than documenting it: `src/config.env.ts`
|
|
251
|
+
sets `file: { downloadRoles: [RoleEnum.S_USER] }` in every environment and
|
|
252
|
+
`src/server/modules/file/file.service.ts` implements `checkRights()`. Copy from there — it is
|
|
253
|
+
compiled and exercised by the test suite; this guide is not.
|
|
254
|
+
|
|
222
255
|
Three new pieces make this possible, and all three were missing before: `createFile()` now forwards
|
|
223
256
|
`serviceOptions.metadata` to storage; `getRawFileInfo()` / `getRawFileInfoByName()` read the raw
|
|
224
257
|
document (the public `getFileInfo()` runs `prepareOutput` → `check()` and strips the very field the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.33.
|
|
3
|
+
"version": "11.33.1",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
package/src/config.env.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { CronExpression } from '@nestjs/schedule';
|
|
|
2
2
|
import * as dotenv from 'dotenv';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
|
|
5
|
+
import { RoleEnum } from './core/common/enums/role.enum';
|
|
5
6
|
import { getEnvironmentConfig } from './core/common/helpers/config.helper';
|
|
6
7
|
import { IServerOptions } from './core/common/interfaces/server-options.interface';
|
|
7
8
|
|
|
@@ -100,6 +101,36 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
100
101
|
autoRegister: false,
|
|
101
102
|
},
|
|
102
103
|
execAfterInit: 'pnpm run docs:bootstrap',
|
|
104
|
+
// File access — the COARSE gate on the two inherited download routes
|
|
105
|
+
// (`GET /files/id/:id`, `GET /files/:filename`), which `FileController` inherits
|
|
106
|
+
// from `CoreFileController` unchanged so this config can reach them.
|
|
107
|
+
//
|
|
108
|
+
// Since 11.33.0 those routes are role-gated and default to `[ADMIN]`. That default
|
|
109
|
+
// is the safe one for a project that has not thought about file access yet, but it
|
|
110
|
+
// is the WRONG one for this reference server: it ships an avatar upload every
|
|
111
|
+
// signed-in user may use, so an admin-only download would make `user.avatar`
|
|
112
|
+
// unreadable by the very user it belongs to.
|
|
113
|
+
//
|
|
114
|
+
// So the gate is widened to "signed in" and the real decision is made per file in
|
|
115
|
+
// `FileService.checkRights()` (own file, or ADMIN). Roles can only answer "may this
|
|
116
|
+
// caller reach the route"; they can never answer "may this caller have THIS file".
|
|
117
|
+
// Widening here is therefore not a relaxation — it MOVES the decision to the layer
|
|
118
|
+
// that can actually make it. It is also what makes the pairing testable at all: under
|
|
119
|
+
// `[ADMIN]` the guard answers first and an ownership rule could never fire, which is
|
|
120
|
+
// how the two halves drifted apart and shipped a bug. See
|
|
121
|
+
// `migration-guides/11.32.x-to-11.33.x.md` § A.3.
|
|
122
|
+
//
|
|
123
|
+
// `uploadRoles` / `deleteRoles` stay at their `[ADMIN]` default. They govern
|
|
124
|
+
// `CoreFileResolver` members only, and this server registers its own `FileResolver`
|
|
125
|
+
// and `FileController` write endpoints, each carrying `@Roles(RoleEnum.ADMIN)`
|
|
126
|
+
// explicitly — so the two knobs are inert here, and leaving them unset keeps the
|
|
127
|
+
// framework default visible rather than restating it.
|
|
128
|
+
//
|
|
129
|
+
// NOTE for markup-driven downloads: anything stricter than `S_EVERYONE` only works
|
|
130
|
+
// from an `<img>` / `<a download>` when the session travels as a COOKIE on a
|
|
131
|
+
// same-site request. A Bearer-only frontend has to fetch such URLs programmatically
|
|
132
|
+
// and build an object URL.
|
|
133
|
+
file: { downloadRoles: [RoleEnum.S_USER] },
|
|
103
134
|
filter: {
|
|
104
135
|
maxLimit: null,
|
|
105
136
|
},
|
|
@@ -224,6 +255,9 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
224
255
|
},
|
|
225
256
|
env: 'development',
|
|
226
257
|
execAfterInit: 'pnpm run docs:bootstrap',
|
|
258
|
+
// Coarse download gate widened to any signed-in user; the per-file rule lives in
|
|
259
|
+
// `FileService.checkRights()`. Full rationale in the `ci` block above.
|
|
260
|
+
file: { downloadRoles: [RoleEnum.S_USER] },
|
|
227
261
|
filter: {
|
|
228
262
|
maxLimit: null,
|
|
229
263
|
},
|
|
@@ -376,6 +410,9 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
376
410
|
autoRegister: false,
|
|
377
411
|
},
|
|
378
412
|
execAfterInit: 'pnpm run docs:bootstrap',
|
|
413
|
+
// Coarse download gate widened to any signed-in user; the per-file rule lives in
|
|
414
|
+
// `FileService.checkRights()`. Full rationale in the `ci` block above.
|
|
415
|
+
file: { downloadRoles: [RoleEnum.S_USER] },
|
|
379
416
|
filter: {
|
|
380
417
|
maxLimit: null,
|
|
381
418
|
},
|
|
@@ -534,6 +571,9 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
534
571
|
autoRegister: false,
|
|
535
572
|
},
|
|
536
573
|
execAfterInit: 'pnpm run docs:bootstrap',
|
|
574
|
+
// Coarse download gate widened to any signed-in user; the per-file rule lives in
|
|
575
|
+
// `FileService.checkRights()`. Full rationale in the `ci` block above.
|
|
576
|
+
file: { downloadRoles: [RoleEnum.S_USER] },
|
|
537
577
|
filter: {
|
|
538
578
|
maxLimit: null,
|
|
539
579
|
},
|
|
@@ -670,6 +710,11 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
670
710
|
},
|
|
671
711
|
env: 'production',
|
|
672
712
|
execAfterInit: 'pnpm run docs:bootstrap',
|
|
713
|
+
// Coarse download gate widened to any signed-in user; the per-file rule lives in
|
|
714
|
+
// `FileService.checkRights()`. Full rationale in the `ci` block above. Deliberately the
|
|
715
|
+
// SAME value as every other environment: a per-file rule that only runs outside
|
|
716
|
+
// production is a rule nobody has actually tested where it matters.
|
|
717
|
+
file: { downloadRoles: [RoleEnum.S_USER] },
|
|
673
718
|
filter: {
|
|
674
719
|
maxLimit: null,
|
|
675
720
|
},
|
|
@@ -65,8 +65,18 @@ decision worth making explicitly.
|
|
|
65
65
|
caller have THIS file". For the latter, write an owner or tenant into the metadata at upload time
|
|
66
66
|
(`createFile(file, { metadata: { ownerId } })`) and compare it in `checkRights()` via
|
|
67
67
|
`getRawFileInfo()` / `getRawFileInfoByName()`. The public `getFileInfo()` strips restricted fields
|
|
68
|
-
and is unusable for the decision.
|
|
69
|
-
same rule works under
|
|
68
|
+
and is unusable for the decision. Both raw lookups consult every store — S3 metadata first, then the
|
|
69
|
+
filesystem store, then GridFS — so the same rule works under any `file.storage`.
|
|
70
|
+
|
|
71
|
+
Handle **both** `checkInputType: 'id'` and `'filename'`. An id-only rule is enough while bytes are
|
|
72
|
+
streamed (the filename route resolves an id and re-checks it), but not with presigned S3 downloads,
|
|
73
|
+
and not for `deleteFileByName()`, which authorizes by name only.
|
|
74
|
+
|
|
75
|
+
**Copy from the executed reference, not from prose:** `src/server/modules/file/file.service.ts`
|
|
76
|
+
implements exactly this rule, and `src/config.env.ts` widens `file.downloadRoles` to `[S_USER]` so
|
|
77
|
+
it is actually reached. Note the internal callers there too — `{ force: true }` on the
|
|
78
|
+
`@Roles(ADMIN)` endpoints, and a real `{ currentUser }` in `AvatarController` — because a rule that
|
|
79
|
+
reads a missing user as "internal, allow" fails open the moment the coarse gate is widened.
|
|
70
80
|
|
|
71
81
|
### 5. Decide how the frontend fetches files
|
|
72
82
|
|
|
@@ -82,8 +92,10 @@ route for exactly the public files and leave the core routes gated.
|
|
|
82
92
|
|
|
83
93
|
- [ ] `pnpm run build` succeeds
|
|
84
94
|
- [ ] Anonymous `GET /files/id/<id>` answers **401**
|
|
85
|
-
- [ ] A signed-in non-privileged user answers **403**
|
|
86
|
-
user out)
|
|
95
|
+
- [ ] A signed-in non-privileged user answers **403** when the ROLE GATE is what refuses (not 401 —
|
|
96
|
+
a 401 makes SPA auth layers log the user out). If you widened `downloadRoles` and let
|
|
97
|
+
`checkRights()` decide instead, the expected answer is **404**, byte-identical to an unknown
|
|
98
|
+
id — a 403 there would confirm the file exists
|
|
87
99
|
- [ ] A caller holding a configured role downloads successfully
|
|
88
100
|
- [ ] If you set `file.downloadRoles`, the value actually takes effect — if it does not, something
|
|
89
101
|
in your controller is overriding the member (see step 2)
|
|
@@ -141,18 +141,49 @@ export class FileService extends CoreFileService {
|
|
|
141
141
|
input: any,
|
|
142
142
|
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
143
143
|
): Promise<boolean> {
|
|
144
|
-
|
|
144
|
+
// Writes, list queries and forced (system) calls stay on the coarse role gate
|
|
145
|
+
if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
145
146
|
return true;
|
|
146
147
|
}
|
|
147
|
-
if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
|
|
148
|
+
if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
148
149
|
return true;
|
|
149
150
|
}
|
|
150
|
-
const raw =
|
|
151
|
-
|
|
151
|
+
const raw =
|
|
152
|
+
options.checkInputType === 'id' ? await this.getRawFileInfo(input) : await this.getRawFileInfoByName(input);
|
|
153
|
+
// Fails closed without a user, and on a file that records no owner.
|
|
154
|
+
return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
152
155
|
}
|
|
153
156
|
}
|
|
154
157
|
```
|
|
155
158
|
|
|
159
|
+
This is not a sketch: it is the rule `src/server/modules/file/file.service.ts` runs, with
|
|
160
|
+
`file: { downloadRoles: [RoleEnum.S_USER] }` in `src/config.env.ts` so the coarse gate actually
|
|
161
|
+
lets it fire. It used to live here and in that file as a **comment**, on the reasoning that the
|
|
162
|
+
`[ADMIN]` default made it unreachable in the reference server anyway — and a commented rule is never
|
|
163
|
+
compiled, never type-checked and never run. That is how a `deleteFileByName()` regression on the
|
|
164
|
+
`filename` branch shipped through a full green suite.
|
|
165
|
+
|
|
166
|
+
**Cover the `filename` branch too, not just `id`.** An id-only rule is enough while bytes are
|
|
167
|
+
streamed, because the filename route resolves an id and checks it again — but not once
|
|
168
|
+
`file.storage: 's3'` with presigned downloads is enabled, where the filename route authorizes on the
|
|
169
|
+
by-name lookup alone and then redirects, and not for `deleteFileByName()`, which authorizes by name
|
|
170
|
+
only.
|
|
171
|
+
|
|
172
|
+
**Never add `if (!options.currentUser) return true`.** It reads as "system-internal call, the guard
|
|
173
|
+
already decided" — but "no user in context" is also exactly what an **anonymous** request looks like.
|
|
174
|
+
While `downloadRoles` is narrower than `S_EVERYONE` the role gate turns those away first, so the
|
|
175
|
+
branch looks harmless; widen the gate, which this very section invites you to do, and it hands every
|
|
176
|
+
file to everyone. The ownership rule evaporates precisely when it starts to matter. The same reason
|
|
177
|
+
makes `!!raw?.metadata?.ownerId` load-bearing: without it, an owner-less file compares
|
|
178
|
+
`String(undefined)` against `String(undefined)` and matches.
|
|
179
|
+
|
|
180
|
+
Callers that really are internal should say so instead of relying on the omission — `{ force: true }`
|
|
181
|
+
where a role decorator already decided (an `@Roles(ADMIN)` admin endpoint), or the real
|
|
182
|
+
`{ currentUser }` where the user is in scope, so that call is **covered** by the ownership rule
|
|
183
|
+
rather than exempt from it. The reference server does both: `src/server/modules/file/` and
|
|
184
|
+
`src/server/modules/user/avatar.controller.ts`. The contract test for the whole rule, covering the
|
|
185
|
+
`id` **and** the `filename` branch, lives in `tests/file-ownership.e2e-spec.ts`.
|
|
186
|
+
|
|
156
187
|
Three pieces make this work, and all three are needed:
|
|
157
188
|
|
|
158
189
|
1. **Write the metadata at upload time** — `createFile(file, { metadata: { ownerId: user.id } })`.
|
|
@@ -557,7 +557,13 @@ export abstract class CoreFileService {
|
|
|
557
557
|
if (!(await this.checkRights(filename, { ...serviceOptions, checkInputType: 'filename' }))) {
|
|
558
558
|
return null;
|
|
559
559
|
}
|
|
560
|
-
|
|
560
|
+
// Forward the caller's context, exactly as deleteFile() does for getFileInfo().
|
|
561
|
+
// Without it this method authorizes the caller and then re-resolves the file with
|
|
562
|
+
// an EMPTY context, so an overridden checkRights() is asked two different
|
|
563
|
+
// questions about one request. For an ownership rule that means the lookup denies
|
|
564
|
+
// — and the caller gets `File not found` for a file that exists and that they were
|
|
565
|
+
// just authorized for, which reads as a missing file rather than a refused one.
|
|
566
|
+
const fileInfo = await this.getFileInfoByName(filename, serviceOptions);
|
|
561
567
|
if (!fileInfo) {
|
|
562
568
|
throw new NotFoundException(`File not found with filename ${filename}`);
|
|
563
569
|
}
|
|
@@ -665,20 +671,48 @@ export abstract class CoreFileService {
|
|
|
665
671
|
* routes. Metadata to compare against must be written at upload time via
|
|
666
672
|
* `serviceOptions.metadata` and read back with `getRawFileInfo()`.
|
|
667
673
|
*
|
|
674
|
+
* **A missing `currentUser` must DENY.** This is the one part of the rule that
|
|
675
|
+
* is easy to get backwards: "no user in context" is NOT "system-internal call"
|
|
676
|
+
* — it is also exactly what an ANONYMOUS request looks like. The coarse gate
|
|
677
|
+
* turns those away while `downloadRoles` is narrower than `S_EVERYONE`, so an
|
|
678
|
+
* `if (!options.currentUser) return true` shortcut looks harmless — right up to
|
|
679
|
+
* the moment a project widens the gate, at which point it hands every file to
|
|
680
|
+
* everyone and the ownership rule evaporates precisely when it starts to
|
|
681
|
+
* matter. Genuinely internal callers say so with `force: true`, or pass the
|
|
682
|
+
* user they already have; see `src/server/modules/file` and
|
|
683
|
+
* `src/server/modules/user/avatar.controller.ts`.
|
|
684
|
+
*
|
|
685
|
+
* **Cover the `filename` branch too, not just `id`.** An id-only rule is
|
|
686
|
+
* enough while bytes are streamed, because the filename route resolves an id
|
|
687
|
+
* and checks it again — but NOT once `s3.presignedDownloads` is enabled, where
|
|
688
|
+
* the filename route authorizes on the by-name lookup alone and then redirects,
|
|
689
|
+
* and not for `deleteFileByName()`, which authorizes by name only.
|
|
690
|
+
*
|
|
691
|
+
* The example below is the rule `src/server/modules/file/file.service.ts`
|
|
692
|
+
* actually runs — it is compiled, type-checked and exercised end to end by
|
|
693
|
+
* every file-touching e2e spec, plus the dedicated contract test in
|
|
694
|
+
* `tests/file-ownership.e2e-spec.ts`. Prefer reading it there over copying
|
|
695
|
+
* from here.
|
|
696
|
+
*
|
|
668
697
|
* @example
|
|
669
698
|
* ```typescript
|
|
670
699
|
* protected override async checkRights(
|
|
671
700
|
* input: any,
|
|
672
701
|
* options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
673
702
|
* ): Promise<boolean> {
|
|
674
|
-
* if (options?.checkInputType !== '
|
|
703
|
+
* if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
675
704
|
* return true;
|
|
676
705
|
* }
|
|
677
|
-
* if (options.currentUser?.hasRole([RoleEnum.ADMIN])) {
|
|
706
|
+
* if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
678
707
|
* return true;
|
|
679
708
|
* }
|
|
680
|
-
* const raw =
|
|
681
|
-
*
|
|
709
|
+
* const raw = options.checkInputType === 'id'
|
|
710
|
+
* ? await this.getRawFileInfo(input)
|
|
711
|
+
* : await this.getRawFileInfoByName(input);
|
|
712
|
+
* // Fails closed without a user: `String(undefined)` cannot equal a real owner id.
|
|
713
|
+
* // Requiring the owner to be PRESENT is load-bearing too — comparing a missing
|
|
714
|
+
* // ownerId against a missing user id would compare 'undefined' with 'undefined'.
|
|
715
|
+
* return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
682
716
|
* }
|
|
683
717
|
* ```
|
|
684
718
|
*/
|
|
@@ -72,7 +72,11 @@ export class FileController extends CoreFileController {
|
|
|
72
72
|
@Get('info/:id')
|
|
73
73
|
@Roles(RoleEnum.ADMIN)
|
|
74
74
|
async getFileInfo(@Param('id') id: string) {
|
|
75
|
-
|
|
75
|
+
// `force`: this route is @Roles(ADMIN) — the guard has already decided, and an overridden
|
|
76
|
+
// checkRights() must not be asked to re-derive that from an absent user. "No currentUser"
|
|
77
|
+
// is also what an anonymous request looks like, so a rule that reads it as "internal call"
|
|
78
|
+
// fails open; saying `force: true` states the intent instead of hiding it in an omission.
|
|
79
|
+
return await this.fileService.getFileInfo(id, { force: true });
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
/**
|
|
@@ -85,6 +89,7 @@ export class FileController extends CoreFileController {
|
|
|
85
89
|
throw new BadRequestException('Missing ID');
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
// `force`: @Roles(ADMIN) above is the whole gate for this endpoint — see getFileInfo().
|
|
93
|
+
return await this.fileService.deleteFile(id, { force: true });
|
|
89
94
|
}
|
|
90
95
|
}
|
|
@@ -28,7 +28,10 @@ export class FileResolver {
|
|
|
28
28
|
@Query(() => FileInfo, { nullable: true })
|
|
29
29
|
@Roles(RoleEnum.ADMIN)
|
|
30
30
|
async getFileInfo(@Args({ name: 'filename', type: () => String }) filename: string) {
|
|
31
|
-
|
|
31
|
+
// `force`: @Roles(ADMIN) is the whole gate for this admin API. Omitting options instead
|
|
32
|
+
// would leave an overridden checkRights() to guess "internal call" from an absent user —
|
|
33
|
+
// indistinguishable from an anonymous request, and therefore the wrong thing to allow on.
|
|
34
|
+
return await this.fileService.getFileInfoByName(filename, { force: true });
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
// ===========================================================================
|
|
@@ -41,7 +44,8 @@ export class FileResolver {
|
|
|
41
44
|
@Mutation(() => FileInfo)
|
|
42
45
|
@Roles(RoleEnum.ADMIN)
|
|
43
46
|
async deleteFile(@Args({ name: 'filename', type: () => String }) filename: string) {
|
|
44
|
-
|
|
47
|
+
// `force`: @Roles(ADMIN) is the whole gate here — see getFileInfo().
|
|
48
|
+
return await this.fileService.deleteFileByName(filename, { force: true });
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/**
|
|
@@ -2,9 +2,11 @@ import { Injectable, Optional } from '@nestjs/common';
|
|
|
2
2
|
import { InjectConnection } from '@nestjs/mongoose';
|
|
3
3
|
import { Connection } from 'mongoose';
|
|
4
4
|
|
|
5
|
+
import { RoleEnum } from '../../../core/common/enums/role.enum';
|
|
5
6
|
import { ConfigService } from '../../../core/common/services/config.service';
|
|
6
7
|
import { CoreS3Service } from '../../../core/common/services/core-s3.service';
|
|
7
|
-
import { CoreFileService } from '../../../core/modules/file/core-file.service';
|
|
8
|
+
import { CoreFileService, FileInputCheckType } from '../../../core/modules/file/core-file.service';
|
|
9
|
+
import { FileServiceOptions } from '../../../core/modules/file/interfaces/file-service-options.interface';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* File service
|
|
@@ -27,35 +29,79 @@ export class FileService extends CoreFileService {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
|
-
*
|
|
32
|
+
* Per-file authorization for the two inherited download routes.
|
|
31
33
|
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
* that cannot fire is worse than none
|
|
34
|
+
* THIS IS DELIBERATELY EXECUTED CODE, NOT AN ILLUSTRATION. It used to be a
|
|
35
|
+
* commented-out `@example` here, on the reasoning that `file.downloadRoles`
|
|
36
|
+
* defaults to `[ADMIN]`, the roles guard therefore answers before the service
|
|
37
|
+
* is reached, and a rule that cannot fire is worse than none. The reasoning was
|
|
38
|
+
* locally sound and globally harmful: a comment is never compiled, never
|
|
39
|
+
* type-checked and never run, so nothing in this repository exercised the
|
|
40
|
+
* `Core*` inheritance seam that every consuming project depends on — and a
|
|
41
|
+
* `deleteFileByName()` bug that dropped `serviceOptions` on the way to its
|
|
42
|
+
* inner lookup shipped green through 2777 tests, to be found downstream hours
|
|
43
|
+
* after release. The commented example was itself wrong (it allowed on a
|
|
44
|
+
* missing `currentUser`), and it was copied verbatim.
|
|
36
45
|
*
|
|
37
|
-
* The
|
|
38
|
-
*
|
|
39
|
-
*
|
|
46
|
+
* The tension is resolved the other way round now: `config.env.ts` widens the
|
|
47
|
+
* coarse gate to `[RoleEnum.S_USER]` in every environment, which is what real
|
|
48
|
+
* projects do, so the rule below actually runs on every download this server
|
|
49
|
+
* serves.
|
|
40
50
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* if (!options.currentUser) {
|
|
47
|
-
* return true; // system-internal call: the guard already decided
|
|
48
|
-
* }
|
|
49
|
-
* if (options.currentUser.hasRole?.([RoleEnum.ADMIN])) {
|
|
50
|
-
* return true;
|
|
51
|
-
* }
|
|
52
|
-
* const raw = await this.getRawFileInfo(input);
|
|
53
|
-
* return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser.id);
|
|
54
|
-
* }
|
|
55
|
-
* ```
|
|
51
|
+
* The rule: ADMIN sees everything; everyone else sees only files whose
|
|
52
|
+
* `metadata.ownerId` is their own id. `AvatarController` writes that metadata
|
|
53
|
+
* at upload time. A file with NO owner recorded — the admin uploads via
|
|
54
|
+
* `/files/upload` and the GraphQL mutations, and TUS uploads, which carry
|
|
55
|
+
* `tusUploadId` but no owner — is therefore ADMIN-only.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* Two properties worth knowing:
|
|
58
|
+
*
|
|
59
|
+
* - **A refusal answers 404, not 403.** That is the framework's doing, not
|
|
60
|
+
* this method's: returning `false` makes the caller answer as if the file
|
|
61
|
+
* did not exist, because a 403 would confirm that the id names a real file
|
|
62
|
+
* and turn the endpoint into an existence oracle.
|
|
63
|
+
* - **A missing `currentUser` DENIES.** This is the one part that is easy to
|
|
64
|
+
* get backwards. "No user in context" is NOT "system-internal call" — it is
|
|
65
|
+
* also exactly what an ANONYMOUS request looks like. Today the coarse gate
|
|
66
|
+
* turns those away before this hook runs, so an `if (!options.currentUser)
|
|
67
|
+
* return true` shortcut looks harmless; widen `downloadRoles` to
|
|
68
|
+
* `S_EVERYONE` and the same branch hands every file to everyone, so the
|
|
69
|
+
* ownership rule evaporates precisely when it starts to matter. Genuinely
|
|
70
|
+
* internal callers say so instead: `FileController` / `FileResolver` pass
|
|
71
|
+
* `{ force: true }` because their `@Roles(ADMIN)` already decided, and
|
|
72
|
+
* `AvatarController` passes the real `{ currentUser }` so its cleanup delete
|
|
73
|
+
* is COVERED by this rule rather than exempt from it.
|
|
74
|
+
*
|
|
75
|
+
* BOTH the `id` and the `filename` branch are covered. Covering only `id` is
|
|
76
|
+
* enough while bytes are streamed, because the filename route resolves an id
|
|
77
|
+
* and checks it again — but not once `s3.presignedDownloads` is enabled, where
|
|
78
|
+
* the filename route authorizes on the by-name lookup alone and then redirects.
|
|
79
|
+
* The by-name half is also where the shipped `deleteFileByName()` bug lived.
|
|
80
|
+
*
|
|
81
|
+
* See `src/core/modules/file/README.md` § Access control, and
|
|
82
|
+
* `tests/file-ownership.e2e-spec.ts` for the end-to-end contract test.
|
|
60
83
|
*/
|
|
84
|
+
protected override async checkRights(
|
|
85
|
+
input: any,
|
|
86
|
+
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
87
|
+
): Promise<boolean> {
|
|
88
|
+
// Writes, list queries and forced (system) calls stay on the coarse role gate.
|
|
89
|
+
if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.currentUser?.hasRole?.([RoleEnum.ADMIN])) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The RAW document on purpose: the public getFileInfo() runs prepareOutput(), which
|
|
98
|
+
// strips `metadata` — the very field this decision rests on.
|
|
99
|
+
const raw =
|
|
100
|
+
options.checkInputType === 'id' ? await this.getRawFileInfo(input) : await this.getRawFileInfoByName(input);
|
|
101
|
+
|
|
102
|
+
// Fails closed on a missing user: `String(undefined)` can never equal a real owner id.
|
|
103
|
+
// Requiring `metadata.ownerId` to be PRESENT is load-bearing too — without it an
|
|
104
|
+
// owner-less file would compare `String(undefined)` against `String(undefined)` and match.
|
|
105
|
+
return !!raw?.metadata?.ownerId && String(raw.metadata.ownerId) === String(options.currentUser?.id);
|
|
106
|
+
}
|
|
61
107
|
}
|
|
@@ -55,9 +55,16 @@ export class AvatarController {
|
|
|
55
55
|
// Drop the replaced file. A failure here must not fail the upload: the new avatar
|
|
56
56
|
// is already stored and referenced, so an orphaned object is a cleanup concern,
|
|
57
57
|
// not a request error.
|
|
58
|
+
//
|
|
59
|
+
// `{ currentUser: user }`, not an empty context: the uploader IS in scope here, so a
|
|
60
|
+
// per-file rule in FileService.checkRights() should COVER this delete (the previous
|
|
61
|
+
// avatar is their own file) rather than be exempted from it. Passing nothing would ask
|
|
62
|
+
// the rule to infer "internal call" from an absent user — which is also what an
|
|
63
|
+
// anonymous request looks like, and is exactly the shortcut that makes an ownership
|
|
64
|
+
// rule evaporate once `file.downloadRoles` is widened.
|
|
58
65
|
if (previousAvatar) {
|
|
59
66
|
try {
|
|
60
|
-
await this.fileService.deleteFile(previousAvatar);
|
|
67
|
+
await this.fileService.deleteFile(previousAvatar, { currentUser: user });
|
|
61
68
|
} catch (error) {
|
|
62
69
|
this.logger.warn(
|
|
63
70
|
`Could not remove previous avatar ${previousAvatar}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|