@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.
@@ -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
- if (options?.checkInputType !== 'id' || options.force) {
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 = await this.getRawFileInfo(input);
217
- return !!raw && String(raw.metadata?.ownerId) === String(options.currentUser?.id);
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.0",
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. `getRawFileInfo()` checks S3 metadata first, then GridFS, so the
69
- same rule works under either `file.storage`.
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** (not 401 a 401 makes SPA auth layers log the
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
- if (options?.checkInputType !== 'id' || options.force) {
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 = await this.getRawFileInfo(input);
151
- return !!raw && String(raw.metadata?.ownerId) === String(options.currentUser?.id);
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
- const fileInfo = await this.getFileInfoByName(filename);
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 !== 'id' || options.force) {
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 = await this.getRawFileInfo(input);
681
- * return !!raw && String(raw.metadata?.ownerId) === String(options.currentUser?.id);
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
- return await this.fileService.getFileInfo(id);
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
- return await this.fileService.deleteFile(id);
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
- return await this.fileService.getFileInfoByName(filename);
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
- return await this.fileService.deleteFileByName(filename);
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
- * NOTE where the per-file rule would go.
32
+ * Per-file authorization for the two inherited download routes.
31
33
  *
32
- * This reference server deliberately does NOT override `checkRights()`. With
33
- * `file.downloadRoles` at its default `[ADMIN]`, the roles guard answers before
34
- * the service is reached, so an owner rule here could never fire — and a rule
35
- * that cannot fire is worse than none, because it reads as protection.
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 pairing only becomes useful once the coarse gate is widened. A project
38
- * that wants "signed-in users may fetch THEIR OWN files" sets
39
- * `file: { downloadRoles: [RoleEnum.S_USER] }` and then overrides:
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
- * ```typescript
42
- * protected override async checkRights(input, options) {
43
- * if (options?.force || options?.checkInputType !== 'id') {
44
- * return true; // writes and filename reads stay on the role gate
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
- * `AvatarController` already writes the `metadata.ownerId` such a rule reads,
58
- * so enabling it in a downstream project is a config change plus this method.
59
- * See `src/core/modules/file/README.md` § Access control.
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'}`,