@lenne.tech/nest-server 11.34.1 → 11.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/architecture.md +3 -1
- package/.claude/rules/configurable-features.md +3 -3
- package/.claude/rules/role-system.md +281 -0
- package/CLAUDE.md +11 -1
- package/FRAMEWORK-API.md +7 -3
- package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
- package/dist/core/common/decorators/nested-type.registry.js +17 -0
- package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
- package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
- package/dist/core/common/decorators/restricted.decorator.js +52 -16
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
- package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
- package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
- package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
- package/dist/core/common/decorators/unified-field.decorator.js +6 -3
- package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
- package/dist/core/common/enums/role.enum.d.ts +8 -0
- package/dist/core/common/enums/role.enum.js +28 -1
- package/dist/core/common/enums/role.enum.js.map +1 -1
- package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
- package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
- package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
- package/dist/core/common/helpers/gridfs.helper.js +1 -1
- package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
- package/dist/core/common/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.service.d.ts +3 -1
- package/dist/core/modules/file/core-file.service.js +62 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-access.helper.d.ts +18 -0
- package/dist/core/modules/file/file-access.helper.js +46 -0
- package/dist/core/modules/file/file-access.helper.js.map +1 -0
- package/dist/core/modules/file/file-metadata.helper.js +1 -1
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
- package/dist/core/modules/file/file-roles.config.d.ts +10 -0
- package/dist/core/modules/file/file-roles.config.js +78 -0
- package/dist/core/modules/file/file-roles.config.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
- package/dist/core/modules/file/file-roles.helper.js +8 -20
- package/dist/core/modules/file/file-roles.helper.js.map +1 -1
- package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
- package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
- package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
- package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
- package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +5 -0
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.service.js +25 -0
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.service.js +34 -4
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/index.d.ts +1 -0
- package/dist/core/modules/tus/index.js +1 -0
- package/dist/core/modules/tus/index.js.map +1 -1
- package/dist/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +2 -1
- package/dist/core/modules/tus/tus.constants.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +5 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +8 -0
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +10 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file.service.js +4 -1
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +26 -6
- package/dist/test/test.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +60 -1
- package/migration-guides/11.34.x-to-11.35.x.md +832 -0
- package/package.json +1 -1
- package/src/core/common/decorators/nested-type.registry.ts +42 -0
- package/src/core/common/decorators/restricted.decorator.ts +149 -21
- package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
- package/src/core/common/decorators/unified-field.decorator.ts +4 -3
- package/src/core/common/enums/role.enum.ts +116 -0
- package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
- package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
- package/src/core/common/helpers/gridfs.helper.ts +18 -2
- package/src/core/common/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 +61 -0
- package/src/core/modules/file/core-file.service.ts +166 -9
- package/src/core/modules/file/file-access.helper.ts +149 -0
- package/src/core/modules/file/file-metadata.helper.ts +13 -5
- package/src/core/modules/file/file-roles.config.ts +202 -0
- package/src/core/modules/file/file-roles.helper.ts +11 -40
- package/src/core/modules/tenant/README.md +20 -1
- package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
- package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
- package/src/core/modules/tenant/core-tenant.module.ts +12 -0
- package/src/core/modules/tenant/core-tenant.service.ts +56 -0
- package/src/core/modules/tus/README.md +46 -7
- package/src/core/modules/tus/core-tus.service.ts +94 -5
- package/src/core/modules/tus/index.ts +1 -0
- package/src/core/modules/tus/tus.constants.ts +17 -1
- package/src/core/modules/user/core-user.service.ts +12 -0
- package/src/core/modules/user/inputs/core-user.input.ts +28 -2
- package/src/core.module.ts +62 -1
- package/src/index.ts +3 -0
- package/src/server/modules/file/file.service.ts +45 -2
- package/src/test/test.helper.ts +54 -11
|
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Optional }
|
|
|
2
2
|
import { InjectModel } from '@nestjs/mongoose';
|
|
3
3
|
import { Model } from 'mongoose';
|
|
4
4
|
|
|
5
|
+
import { looksLikeGlobalOnlyRole, looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
|
|
5
6
|
import { ConfigService } from '../../common/services/config.service';
|
|
6
7
|
import { RequestContext } from '../../common/services/request-context.service';
|
|
7
8
|
import { CoreTenantMemberModel } from './core-tenant-member.model';
|
|
@@ -101,6 +102,7 @@ export class CoreTenantService {
|
|
|
101
102
|
throw new BadRequestException('userId must not be empty');
|
|
102
103
|
}
|
|
103
104
|
const effectiveRole = role ?? this.getDefaultRole();
|
|
105
|
+
assertAssignableMembershipRole(effectiveRole);
|
|
104
106
|
|
|
105
107
|
// Check for existing membership
|
|
106
108
|
const existing = await this.getMembership(tenantId, userId);
|
|
@@ -188,6 +190,7 @@ export class CoreTenantService {
|
|
|
188
190
|
if (!role?.trim()) {
|
|
189
191
|
throw new BadRequestException('role must not be empty');
|
|
190
192
|
}
|
|
193
|
+
assertAssignableMembershipRole(role);
|
|
191
194
|
const highestRole = this.getHighestRole();
|
|
192
195
|
|
|
193
196
|
// If demoting from highest role, ensure it's not the last one
|
|
@@ -242,3 +245,56 @@ export class CoreTenantService {
|
|
|
242
245
|
});
|
|
243
246
|
}
|
|
244
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Refuse a membership role that would cross the tenant boundary.
|
|
251
|
+
*
|
|
252
|
+
* Membership roles are customer-assigned free text, and whoever may manage members is typically a
|
|
253
|
+
* tenant owner — a customer. Two families of name must never become one:
|
|
254
|
+
*
|
|
255
|
+
* - **system roles** (`s_*`) are runtime-context questions ("is this the owner of the record?"),
|
|
256
|
+
* not stored roles. A membership named `s_self` used to satisfy `@Restricted(S_SELF)` on
|
|
257
|
+
* arbitrary records.
|
|
258
|
+
* - **global-only roles** (`RoleEnum.ADMIN`) are platform authority. A membership named `admin`
|
|
259
|
+
* used to satisfy `@Roles(RoleEnum.ADMIN)` — the global role — inside tenant context.
|
|
260
|
+
*
|
|
261
|
+
* This is the SECOND layer, not the protection itself. The guards resolve each required role
|
|
262
|
+
* against its own source (`user.roles` vs `membership.role`), so an already-stored dangerous name
|
|
263
|
+
* is inert even without this check — which matters, because a future `RoleEnum` addition would
|
|
264
|
+
* otherwise turn every pre-existing membership of that name into a hole retroactively. This check
|
|
265
|
+
* only stops new ones from being created, and gives a clear error instead of silent inertness.
|
|
266
|
+
*/
|
|
267
|
+
export function assertAssignableMembershipRole(role: string): void {
|
|
268
|
+
if (looksLikeSystemRole(role)) {
|
|
269
|
+
throw new BadRequestException(
|
|
270
|
+
`A system role (${SYSTEM_ROLE_PREFIX}*) must never be used as a tenant membership role: ${role}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (looksLikeGlobalOnlyRole(role)) {
|
|
274
|
+
throw new BadRequestException(
|
|
275
|
+
`"${role}" is a global role and must never be used as a tenant membership role — ` +
|
|
276
|
+
'use a tenant-specific name such as "tenantAdmin" instead',
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Deny by default, when enabled: only roles the project actually declared.
|
|
281
|
+
//
|
|
282
|
+
// An undeclared role can never GRANT anything either way — the guards match only declared tenant
|
|
283
|
+
// roles — so this does not change access decisions. What it changes is WHEN the mistake surfaces:
|
|
284
|
+
// as a 400 at assignment time, instead of as a membership that silently authorizes nothing while
|
|
285
|
+
// looking perfectly fine in a members list.
|
|
286
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
287
|
+
if (config?.strictMembershipRoles) {
|
|
288
|
+
const declared = new Set([
|
|
289
|
+
...Object.keys(config.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY),
|
|
290
|
+
...(config.additionalMembershipRoles ?? []),
|
|
291
|
+
]);
|
|
292
|
+
if (!declared.has(role)) {
|
|
293
|
+
throw new BadRequestException(
|
|
294
|
+
`"${role}" is not a declared tenant role. Declared: [${[...declared].sort().join(', ')}]. ` +
|
|
295
|
+
'Add it to multiTenancy.roleHierarchy or multiTenancy.additionalMembershipRoles, ' +
|
|
296
|
+
'or disable multiTenancy.strictMembershipRoles.',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -380,13 +380,52 @@ query {
|
|
|
380
380
|
|
|
381
381
|
The following metadata is stored with each GridFS file:
|
|
382
382
|
|
|
383
|
-
| Field | Source
|
|
384
|
-
| ------------------ |
|
|
385
|
-
| `filename` | From TUS `Upload-Metadata` header
|
|
386
|
-
| `contentType` | From TUS `filetype` metadata
|
|
387
|
-
| `tusUploadId` | Original TUS upload ID
|
|
388
|
-
| `originalMetadata` | All TUS metadata
|
|
389
|
-
| `
|
|
383
|
+
| Field | Source |
|
|
384
|
+
| ------------------ | ------------------------------------- |
|
|
385
|
+
| `filename` | From TUS `Upload-Metadata` header |
|
|
386
|
+
| `contentType` | From TUS `filetype` metadata |
|
|
387
|
+
| `tusUploadId` | Original TUS upload ID |
|
|
388
|
+
| `originalMetadata` | All TUS metadata |
|
|
389
|
+
| `ownerId` | The authenticated uploader (11.35.0+) |
|
|
390
|
+
| `uploadedAt` | Completion timestamp |
|
|
391
|
+
|
|
392
|
+
### Upload ownership (11.35.0+)
|
|
393
|
+
|
|
394
|
+
`tus.roles` decides **who may reach the endpoint**. It says nothing about **which upload** a caller may
|
|
395
|
+
touch — and the protocol is built around a per-upload URL: after `POST /tus` the client holds
|
|
396
|
+
`/tus/<id>` and uses it for `HEAD` (offset), `PATCH` (append bytes) and `DELETE` (terminate). Until
|
|
397
|
+
11.35.0 all three carried only that coarse gate, so any other authenticated caller who learned an id
|
|
398
|
+
could resume, **overwrite** or destroy somebody else's upload. Overwriting is the sharp end: the bytes
|
|
399
|
+
are migrated into the file store under the ORIGINAL uploader's filename.
|
|
400
|
+
|
|
401
|
+
Two things changed:
|
|
402
|
+
|
|
403
|
+
- **`onUploadCreate` records the creator** in the upload's own metadata under
|
|
404
|
+
`TUS_OWNER_METADATA_KEY` (`ltOwnerId`). It **overwrites** any client-supplied value — metadata
|
|
405
|
+
arrives in the `Upload-Metadata` header, so a merged value would let a caller name somebody else as
|
|
406
|
+
the owner.
|
|
407
|
+
- **`onIncomingRequest` refuses a request naming an upload the caller does not own**, with **404** — the
|
|
408
|
+
same "a refusal is indistinguishable from a missing resource" policy the file module uses, so the
|
|
409
|
+
endpoint is not an existence oracle for upload ids.
|
|
410
|
+
|
|
411
|
+
The finished file's metadata gains `ownerId`, which is the key
|
|
412
|
+
`CoreFileService.checkRights()` documents — so a tus-uploaded file can finally satisfy a per-file
|
|
413
|
+
ownership rule. Before this it could not: the rule failed closed for everyone but ADMIN, and a project
|
|
414
|
+
following the documented pattern ended up with files nobody could download.
|
|
415
|
+
|
|
416
|
+
**An owner-LESS upload stays reachable by anyone who may reach the endpoint.** Deliberately: uploads
|
|
417
|
+
created before 11.35.0 carry no owner, and neither does an intentionally public form
|
|
418
|
+
(`tus.roles: [RoleEnum.S_EVERYONE]`). Denying those would break in-flight uploads on upgrade and a
|
|
419
|
+
documented configuration. What is closed is an upload that HAS an owner being touched by somebody else.
|
|
420
|
+
|
|
421
|
+
Both `readRequestUserId()` and `assertUploadOwnership()` are `protected` — override to read the owner
|
|
422
|
+
from elsewhere (an API key, a signed form token), or to let a support role resume any upload.
|
|
423
|
+
|
|
424
|
+
> **Note for a custom service:** `@tus/server` v2 does NOT hand the Express request to its hooks. It
|
|
425
|
+
> converts the Node request into a WHATWG `ServerRequest` first, so anything a guard attached lives on
|
|
426
|
+
> the original request, reachable through `runtime.node.req` — which is why `readRequestUserId()` checks
|
|
427
|
+
> there as well. Reading only `req.user` finds nothing and every upload silently becomes owner-less,
|
|
428
|
+
> failing in the permissive direction.
|
|
390
429
|
|
|
391
430
|
---
|
|
392
431
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
FilesystemFileHelper,
|
|
19
19
|
} from '../file/filesystem-file.helper';
|
|
20
20
|
import { S3_FILES_COLLECTION, S3FileHelper } from '../file/s3-file.helper';
|
|
21
|
+
import { TUS_OWNER_METADATA_KEY } from './tus.constants';
|
|
21
22
|
import { TusRedisLocker } from './tus-redis-locker';
|
|
22
23
|
import {
|
|
23
24
|
DEFAULT_TUS_ALLOWED_HEADERS,
|
|
@@ -181,6 +182,11 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
181
182
|
const contentType = metadata.filetype || 'application/octet-stream';
|
|
182
183
|
const fileMetadata = {
|
|
183
184
|
originalMetadata: metadata,
|
|
185
|
+
// The key `CoreFileService.checkRights()` documents for a per-file ownership rule. Without it
|
|
186
|
+
// a tus-uploaded file could never satisfy that rule — it failed CLOSED for everyone but
|
|
187
|
+
// ADMIN, so a project following the documented pattern ended up with undownloadable files.
|
|
188
|
+
// Written from the SERVER-recorded owner, never from the client's own metadata.
|
|
189
|
+
...(metadata[TUS_OWNER_METADATA_KEY] ? { ownerId: metadata[TUS_OWNER_METADATA_KEY] } : {}),
|
|
184
190
|
tusUploadId: upload.id,
|
|
185
191
|
uploadedAt: new Date(),
|
|
186
192
|
};
|
|
@@ -460,6 +466,79 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
460
466
|
await this.deleteStagedUpload(upload.id);
|
|
461
467
|
}
|
|
462
468
|
|
|
469
|
+
/**
|
|
470
|
+
* The authenticated caller's id, as the upstream role guard left it on the request.
|
|
471
|
+
*
|
|
472
|
+
* `CoreTusController.handleTus(req, res)` receives the Express request, so `req.user` is whatever
|
|
473
|
+
* the guard put there. But `@tus/server` v2 does NOT hand that object to its hooks: `handle()`
|
|
474
|
+
* converts the Node request into a WHATWG `ServerRequest` (srvx) first, and anything the guard
|
|
475
|
+
* attached lives on the ORIGINAL request, reachable through `runtime.node.req`. Reading only
|
|
476
|
+
* `req.user` therefore finds nothing and every upload silently becomes owner-less — which fails in
|
|
477
|
+
* the permissive direction, so it would not have shown up as an error anywhere.
|
|
478
|
+
*
|
|
479
|
+
* Returns `undefined` for an unauthenticated request, which is a legitimate state: a project may
|
|
480
|
+
* open the gate with `tus.roles: [RoleEnum.S_EVERYONE]` for a public form.
|
|
481
|
+
*
|
|
482
|
+
* Override to read the owner from somewhere else (an API key, a signed form token).
|
|
483
|
+
*/
|
|
484
|
+
protected readRequestUserId(req: any): string | undefined {
|
|
485
|
+
const id = req?.user?.id ?? req?.runtime?.node?.req?.user?.id ?? req?.context?.user?.id;
|
|
486
|
+
return id === undefined || id === null || id === '' ? undefined : String(id);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Refuse a request that names an upload the caller does not own.
|
|
491
|
+
*
|
|
492
|
+
* `tus.roles` is the coarse gate — who may reach the endpoint at all. This is the fine one: HEAD
|
|
493
|
+
* (read the offset), PATCH (APPEND BYTES) and DELETE (terminate) all address an upload by its URL,
|
|
494
|
+
* and without this any authenticated caller who learns an id could resume, overwrite or destroy
|
|
495
|
+
* somebody else's upload. Appending is the sharpest: those bytes are then migrated into the file
|
|
496
|
+
* store under the ORIGINAL uploader's filename, so the attacker's content is served as the victim's.
|
|
497
|
+
*
|
|
498
|
+
* **Refuses with 404, never 403** — the same policy `CoreFileService.checkRights()` states: a
|
|
499
|
+
* refusal must be indistinguishable from an upload that does not exist, or the endpoint becomes an
|
|
500
|
+
* existence oracle for ids that are not secrets.
|
|
501
|
+
*
|
|
502
|
+
* **An OWNER-LESS upload stays reachable by anyone who may reach the endpoint.** That is deliberate
|
|
503
|
+
* and load-bearing for backward compatibility: uploads created before 11.35.0 carry no owner, and
|
|
504
|
+
* so does every upload of a project that deliberately runs the endpoint public. Denying those would
|
|
505
|
+
* turn an upgrade into a fleet-wide breakage of in-flight uploads and break a documented
|
|
506
|
+
* configuration. What is closed is the case that actually leaks: an upload that HAS an owner being
|
|
507
|
+
* touched by somebody else.
|
|
508
|
+
*
|
|
509
|
+
* Override to widen this — e.g. to let a support role resume any upload.
|
|
510
|
+
*/
|
|
511
|
+
protected async assertUploadOwnership(req: any, uploadId: string): Promise<void> {
|
|
512
|
+
// No id: this is a creation (POST) or a server-capability request (OPTIONS). Nothing to own yet.
|
|
513
|
+
if (!uploadId) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let owner: string | undefined;
|
|
518
|
+
try {
|
|
519
|
+
const upload = await (this.tusServer as any)?.datastore?.getUpload?.(uploadId);
|
|
520
|
+
const recorded = upload?.metadata?.[TUS_OWNER_METADATA_KEY];
|
|
521
|
+
owner = recorded === undefined || recorded === null ? undefined : String(recorded);
|
|
522
|
+
} catch {
|
|
523
|
+
// Unknown id, or a store that cannot answer right now. Say nothing and let the tus server
|
|
524
|
+
// produce its own 404 — inventing a refusal here would also refuse legitimate retries during a
|
|
525
|
+
// transient store outage.
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!owner) {
|
|
530
|
+
return; // owner-less upload — see the note above
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const caller = this.readRequestUserId(req);
|
|
534
|
+
if (caller && caller === owner) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.logger.warn(`Refused tus request for upload ${uploadId}: caller is not its owner`);
|
|
539
|
+
throw Object.assign(new Error('Upload not found'), { body: 'Upload not found', status_code: 404 });
|
|
540
|
+
}
|
|
541
|
+
|
|
463
542
|
/**
|
|
464
543
|
* Validate file type against allowedTypes configuration
|
|
465
544
|
*
|
|
@@ -519,11 +598,20 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
519
598
|
datastore,
|
|
520
599
|
...(locker ? { locker } : {}),
|
|
521
600
|
maxSize: this.config.maxSize,
|
|
522
|
-
|
|
601
|
+
// Every request that names an upload — HEAD (offset), PATCH (append bytes), DELETE
|
|
602
|
+
// (terminate) — is checked against the upload's recorded owner. `tus.roles` alone cannot do
|
|
603
|
+
// this: it decides who may reach the endpoint, not which upload they may touch, and the tus
|
|
604
|
+
// protocol is built around a per-upload URL. Appending is the sharp end — bytes PATCHed into
|
|
605
|
+
// somebody else's upload are migrated into the file store under THEIR filename.
|
|
606
|
+
onIncomingRequest: async (req, uploadId) => this.assertUploadOwnership(req, uploadId),
|
|
607
|
+
onUploadCreate: async (req, upload) => {
|
|
608
|
+
// Record the creator FIRST, so the ownership check below has something to compare against.
|
|
609
|
+
// Overwrites rather than merges: see TUS_OWNER_METADATA_KEY.
|
|
610
|
+
const metadata = { ...upload.metadata, [TUS_OWNER_METADATA_KEY]: this.readRequestUserId(req) ?? null };
|
|
611
|
+
|
|
523
612
|
// Validate file type if allowedTypes is configured
|
|
524
613
|
if (this.config.allowedTypes && this.config.allowedTypes.length > 0) {
|
|
525
|
-
const
|
|
526
|
-
const filetype = metadata.filetype;
|
|
614
|
+
const filetype = this.parseMetadata(upload.metadata).filetype;
|
|
527
615
|
|
|
528
616
|
if (!this.validateFileType(filetype)) {
|
|
529
617
|
const allowedList = this.config.allowedTypes.join(', ');
|
|
@@ -541,8 +629,9 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
541
629
|
}
|
|
542
630
|
}
|
|
543
631
|
|
|
544
|
-
//
|
|
545
|
-
|
|
632
|
+
// Returning the metadata is what PERSISTS the owner — the tus server writes the returned
|
|
633
|
+
// value onto the upload. Returning `{}` here would keep the client's own metadata verbatim.
|
|
634
|
+
return { metadata };
|
|
546
635
|
},
|
|
547
636
|
onUploadFinish: async (_req, upload) => {
|
|
548
637
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dependency-injection tokens of the TUS module.
|
|
2
|
+
* Dependency-injection tokens AND cross-file constants of the TUS module.
|
|
3
3
|
*
|
|
4
4
|
* They live in a dedicated, import-free leaf file — never in `tus.module.ts` or a service — so that
|
|
5
5
|
* no file needing a token has to import the module (or vice versa) and close an import cycle.
|
|
@@ -23,3 +23,19 @@
|
|
|
23
23
|
* Injected type: `Required<ITusConfig>`.
|
|
24
24
|
*/
|
|
25
25
|
export const TUS_CONFIG = 'TUS_CONFIG';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Metadata key recording WHO created a tus upload, in the upload's own (staged) metadata.
|
|
29
|
+
*
|
|
30
|
+
* Framework-owned and ALWAYS overwritten in `onUploadCreate` — metadata otherwise arrives from the
|
|
31
|
+
* client in the `Upload-Metadata` header, so a merged value would let a caller declare somebody else
|
|
32
|
+
* as the owner and hand themselves access to the victim's upload URL.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately NOT `ownerId`: that is the key of the FINISHED FILE's metadata (the one
|
|
35
|
+
* `CoreFileService.checkRights()` documents), and keeping the two distinct means a project's own
|
|
36
|
+
* `ownerId` metadata on a file cannot be confused with the upload-time record.
|
|
37
|
+
*
|
|
38
|
+
* It lives here rather than in the service for the same reason the token above does — and because
|
|
39
|
+
* `tests/unit/import-cycle-invariants.spec.ts` enforces exactly that placement.
|
|
40
|
+
*/
|
|
41
|
+
export const TUS_OWNER_METADATA_KEY = 'ltOwnerId';
|
|
@@ -4,6 +4,7 @@ import crypto = require('crypto');
|
|
|
4
4
|
import { sha256 } from 'js-sha256';
|
|
5
5
|
import { Document, Model } from 'mongoose';
|
|
6
6
|
|
|
7
|
+
import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
|
|
7
8
|
import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
8
9
|
import { ServiceOptions } from '../../common/interfaces/service-options.interface';
|
|
9
10
|
import { ConfigService } from '../../common/services/config.service';
|
|
@@ -263,6 +264,17 @@ export abstract class CoreUserService<
|
|
|
263
264
|
throw new BadRequestException('Roles contains invalid values');
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
// Reject system roles (s_*). This is the framework's canonical "assign roles" API and it writes
|
|
268
|
+
// straight through findByIdAndUpdate, so neither MapAndValidatePipe nor check() sees the values.
|
|
269
|
+
// mongooseSystemRolePlugin is the backstop below this; the explicit check here exists to fail
|
|
270
|
+
// before the DB round-trip and with a message naming the offending values.
|
|
271
|
+
const systemRoles = roles.filter((role) => looksLikeSystemRole(role));
|
|
272
|
+
if (systemRoles.length) {
|
|
273
|
+
throw new BadRequestException(
|
|
274
|
+
`System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles: ${systemRoles.join(', ')}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
266
278
|
// Update and return user
|
|
267
279
|
return this.process(
|
|
268
280
|
async () => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { InputType } from '@nestjs/graphql';
|
|
2
|
-
import { IsEmail } from 'class-validator';
|
|
2
|
+
import { IsEmail, IsString, Matches } from 'class-validator';
|
|
3
3
|
|
|
4
4
|
import { Restricted } from '../../../common/decorators/restricted.decorator';
|
|
5
5
|
import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
|
|
6
6
|
import { ProcessType } from '../../../common/enums/process-type.enum';
|
|
7
|
-
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
7
|
+
import { RoleEnum, SYSTEM_ROLE_PREFIX, SYSTEM_ROLE_REJECT_PATTERN } from '../../../common/enums/role.enum';
|
|
8
8
|
import { CoreInput } from '../../../common/inputs/core-input.input';
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -50,12 +50,38 @@ export abstract class CoreUserInput extends CoreInput {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Roles of the user
|
|
53
|
+
*
|
|
54
|
+
* System roles (`s_*` prefix, see RoleEnum) are runtime-context checks and must NEVER be stored:
|
|
55
|
+
* `hasRole` is a plain string intersection, so a stored `s_self` would satisfy every S_SELF
|
|
56
|
+
* check (e.g. update/delete of ARBITRARY users) without the account carrying a real role.
|
|
57
|
+
* The @Restricted above limits WHO may send the field; this validator limits WHAT it may hold.
|
|
58
|
+
*
|
|
59
|
+
* A rejected value answers HTTP 400 (`Validation failed for 1 field: roles (matches)`, with the
|
|
60
|
+
* per-field message in the response body). `CoreUserCreateInput` inherits this field, so sign-up
|
|
61
|
+
* and user creation are covered too.
|
|
62
|
+
*
|
|
63
|
+
* This validator is the FIRST of three layers, not the only one — it gives the best error
|
|
64
|
+
* message, but a subclass that redeclares `roles` replaces it (MapAndValidatePipe walks the
|
|
65
|
+
* prototype chain child-first and skips a property once a child class has validated it), and
|
|
66
|
+
* `force: true` / direct Mongoose writes never reach it at all. `CoreUserService.setRoles()` and
|
|
67
|
+
* the unconditional `mongooseSystemRolePlugin` close those paths.
|
|
53
68
|
*/
|
|
54
69
|
@Restricted({ processType: ProcessType.INPUT, roles: RoleEnum.ADMIN })
|
|
55
70
|
@UnifiedField({
|
|
71
|
+
description: 'Roles of the user (system roles with s_ prefix are rejected)',
|
|
56
72
|
isArray: true,
|
|
57
73
|
isOptional: true,
|
|
58
74
|
type: String,
|
|
75
|
+
// `opts` already carries `each: true` for array fields — do not hardcode it, or array-ness has
|
|
76
|
+
// two sources of truth. Note that supplying `validator` REPLACES the built-in `IsString`, so it
|
|
77
|
+
// is re-declared here explicitly rather than relying on `matches()` rejecting non-strings.
|
|
78
|
+
validator: (opts) => [
|
|
79
|
+
IsString(opts),
|
|
80
|
+
Matches(SYSTEM_ROLE_REJECT_PATTERN, {
|
|
81
|
+
...opts,
|
|
82
|
+
message: `System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles`,
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
59
85
|
})
|
|
60
86
|
roles?: string[] = undefined;
|
|
61
87
|
|
package/src/core.module.ts
CHANGED
|
@@ -8,6 +8,10 @@ import graphqlUploadExpress = require('graphql-upload/graphqlUploadExpress.js');
|
|
|
8
8
|
import mongoose from 'mongoose';
|
|
9
9
|
|
|
10
10
|
import { merge } from './core/common/helpers/config.helper';
|
|
11
|
+
import {
|
|
12
|
+
buildRequestContextAwareExecute,
|
|
13
|
+
buildRequestContextAwareSubscribe,
|
|
14
|
+
} from './core/common/helpers/graphql-ws-context.helper';
|
|
11
15
|
import { CheckResponseInterceptor } from './core/common/interceptors/check-response.interceptor';
|
|
12
16
|
import { CheckSecurityInterceptor } from './core/common/interceptors/check-security.interceptor';
|
|
13
17
|
import { ResponseModelInterceptor } from './core/common/interceptors/response-model.interceptor';
|
|
@@ -31,6 +35,7 @@ import { mongooseIdPlugin } from './core/common/plugins/mongoose-id.plugin';
|
|
|
31
35
|
import { mongooseAuditFieldsPlugin } from './core/common/plugins/mongoose-audit-fields.plugin';
|
|
32
36
|
import { mongoosePasswordPlugin } from './core/common/plugins/mongoose-password.plugin';
|
|
33
37
|
import { mongooseRoleGuardPlugin } from './core/common/plugins/mongoose-role-guard.plugin';
|
|
38
|
+
import { mongooseSystemRolePlugin } from './core/common/plugins/mongoose-system-role.plugin';
|
|
34
39
|
import { mongooseTenantPlugin } from './core/common/plugins/mongoose-tenant.plugin';
|
|
35
40
|
import { ConfigService } from './core/common/services/config.service';
|
|
36
41
|
import { CoreCronJobsInitializer } from './core/common/services/core-cron-jobs.initializer';
|
|
@@ -46,7 +51,7 @@ import { CoreBetterAuthUserMapper } from './core/modules/better-auth/core-better
|
|
|
46
51
|
import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-auth.module';
|
|
47
52
|
import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
|
|
48
53
|
import { ErrorCodeModule } from './core/modules/error-code/error-code.module';
|
|
49
|
-
import { applyFileRoles } from './core/modules/file/file-roles.helper';
|
|
54
|
+
import { applyFileRoles, warnOnPresignedDownloadsWithRestrictedRoles } from './core/modules/file/file-roles.helper';
|
|
50
55
|
import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
|
|
51
56
|
import { CoreHubModule } from './core/modules/hub/core-hub.module';
|
|
52
57
|
import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config.helper';
|
|
@@ -253,6 +258,12 @@ export class CoreModule implements NestModule {
|
|
|
253
258
|
if (config.security?.mongooseRoleGuardPlugin !== false) {
|
|
254
259
|
connection.plugin(mongooseRoleGuardPlugin);
|
|
255
260
|
}
|
|
261
|
+
// Refuse to store system roles (s_*) in a roles array. Deliberately NOT configurable and
|
|
262
|
+
// registered independently of the role guard above: that one decides WHO may change roles
|
|
263
|
+
// (and can be switched off), this one decides WHICH values may exist at all. A stored
|
|
264
|
+
// 's_self' satisfies every S_SELF check on arbitrary users, so there is no configuration
|
|
265
|
+
// under which writing one is legitimate.
|
|
266
|
+
connection.plugin(mongooseSystemRolePlugin);
|
|
256
267
|
// Add audit fields plugin (enabled by default, opt-out via config)
|
|
257
268
|
if (config.security?.mongooseAuditFieldsPlugin !== false) {
|
|
258
269
|
connection.plugin(mongooseAuditFieldsPlugin);
|
|
@@ -273,6 +284,11 @@ export class CoreModule implements NestModule {
|
|
|
273
284
|
// function and opt out of this; see applyFileRoles() for why.
|
|
274
285
|
applyFileRoles(config.file);
|
|
275
286
|
|
|
287
|
+
// Presigned downloads hand out a bearer URL that ignores roles after it is issued, so pairing
|
|
288
|
+
// them with a restricted downloadRoles is usually an oversight. Warn rather than fail — the
|
|
289
|
+
// combination has legitimate uses, it just should not be reached by accident.
|
|
290
|
+
warnOnPresignedDownloadsWithRestrictedRoles(config.s3, config.file);
|
|
291
|
+
|
|
276
292
|
// Check secrets
|
|
277
293
|
const jwtConfig = config.jwt;
|
|
278
294
|
if (jwtConfig?.secret && jwtConfig.secret && jwtConfig.refresh && jwtConfig.refresh.secret === jwtConfig.secret) {
|
|
@@ -593,6 +609,21 @@ export class CoreModule implements NestModule {
|
|
|
593
609
|
cors,
|
|
594
610
|
installSubscriptionHandlers: true,
|
|
595
611
|
subscriptions: {
|
|
612
|
+
// WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
|
|
613
|
+
// `subscribe` from its own options and hands them to BOTH WS transports, so these two
|
|
614
|
+
// are the only place where a whole WS operation is reachable as one call. They must sit
|
|
615
|
+
// INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
|
|
616
|
+
// `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
|
|
617
|
+
// silently dropped.
|
|
618
|
+
//
|
|
619
|
+
// Without them a WS operation runs with NO `RequestContext` at all — no Express
|
|
620
|
+
// middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
|
|
621
|
+
// subscription context — and `mongooseTenantPlugin` reads "no context" as "system
|
|
622
|
+
// operation, no filter". A tenant-scoped read while delivering a subscription message
|
|
623
|
+
// therefore returned EVERY tenant's rows, with the plugin's safety net unable to
|
|
624
|
+
// notice. HTTP does not pass through these (Apollo runs its own pipeline).
|
|
625
|
+
execute: buildRequestContextAwareExecute(),
|
|
626
|
+
subscribe: buildRequestContextAwareSubscribe(),
|
|
596
627
|
'graphql-ws': {
|
|
597
628
|
context: ({ extra }) => extra,
|
|
598
629
|
onConnect: async (context: Context<any, any>) => {
|
|
@@ -688,6 +719,21 @@ export class CoreModule implements NestModule {
|
|
|
688
719
|
cors,
|
|
689
720
|
installSubscriptionHandlers: true,
|
|
690
721
|
subscriptions: {
|
|
722
|
+
// WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
|
|
723
|
+
// `subscribe` from its own options and hands them to BOTH WS transports, so these two
|
|
724
|
+
// are the only place where a whole WS operation is reachable as one call. They must sit
|
|
725
|
+
// INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
|
|
726
|
+
// `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
|
|
727
|
+
// silently dropped.
|
|
728
|
+
//
|
|
729
|
+
// Without them a WS operation runs with NO `RequestContext` at all — no Express
|
|
730
|
+
// middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
|
|
731
|
+
// subscription context — and `mongooseTenantPlugin` reads "no context" as "system
|
|
732
|
+
// operation, no filter". A tenant-scoped read while delivering a subscription message
|
|
733
|
+
// therefore returned EVERY tenant's rows, with the plugin's safety net unable to
|
|
734
|
+
// notice. HTTP does not pass through these (Apollo runs its own pipeline).
|
|
735
|
+
execute: buildRequestContextAwareExecute(),
|
|
736
|
+
subscribe: buildRequestContextAwareSubscribe(),
|
|
691
737
|
'graphql-ws': {
|
|
692
738
|
context: ({ extra }) => extra,
|
|
693
739
|
onConnect: async (context: Context<any, any>) => {
|
|
@@ -800,6 +846,21 @@ export class CoreModule implements NestModule {
|
|
|
800
846
|
cors,
|
|
801
847
|
installSubscriptionHandlers: true,
|
|
802
848
|
subscriptions: {
|
|
849
|
+
// WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
|
|
850
|
+
// `subscribe` from its own options and hands them to BOTH WS transports, so these two
|
|
851
|
+
// are the only place where a whole WS operation is reachable as one call. They must sit
|
|
852
|
+
// INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
|
|
853
|
+
// `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
|
|
854
|
+
// silently dropped.
|
|
855
|
+
//
|
|
856
|
+
// Without them a WS operation runs with NO `RequestContext` at all — no Express
|
|
857
|
+
// middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
|
|
858
|
+
// subscription context — and `mongooseTenantPlugin` reads "no context" as "system
|
|
859
|
+
// operation, no filter". A tenant-scoped read while delivering a subscription message
|
|
860
|
+
// therefore returned EVERY tenant's rows, with the plugin's safety net unable to
|
|
861
|
+
// notice. HTTP does not pass through these (Apollo runs its own pipeline).
|
|
862
|
+
execute: buildRequestContextAwareExecute(),
|
|
863
|
+
subscribe: buildRequestContextAwareSubscribe(),
|
|
803
864
|
'graphql-ws': {
|
|
804
865
|
context: ({ extra }) => extra,
|
|
805
866
|
onConnect: async (context: Context<any, any>) => {
|
package/src/index.ts
CHANGED
|
@@ -81,6 +81,7 @@ export * from './core/common/plugins/mongoose-id.plugin';
|
|
|
81
81
|
export * from './core/common/plugins/mongoose-audit-fields.plugin';
|
|
82
82
|
export * from './core/common/plugins/mongoose-password.plugin';
|
|
83
83
|
export * from './core/common/plugins/mongoose-role-guard.plugin';
|
|
84
|
+
export * from './core/common/plugins/mongoose-system-role.plugin';
|
|
84
85
|
export * from './core/common/plugins/mongoose-tenant.plugin';
|
|
85
86
|
export * from './core/common/scalars/any.scalar';
|
|
86
87
|
export * from './core/common/scalars/date-timestamp.scalar';
|
|
@@ -176,6 +177,7 @@ export * from './core/modules/file/core-file-info.model';
|
|
|
176
177
|
export * from './core/modules/file/core-file.controller';
|
|
177
178
|
export * from './core/modules/file/core-file.resolver';
|
|
178
179
|
export * from './core/modules/file/core-file.service';
|
|
180
|
+
export * from './core/modules/file/file-access.helper';
|
|
179
181
|
export * from './core/modules/file/file-roles.helper';
|
|
180
182
|
export * from './core/modules/file/file-storage.helper';
|
|
181
183
|
export * from './core/modules/file/filesystem-file.helper';
|
|
@@ -236,6 +238,7 @@ export * from './core/modules/tenant/core-tenant-member.model';
|
|
|
236
238
|
export * from './core/modules/tenant/core-tenant.decorators';
|
|
237
239
|
export * from './core/modules/tenant/core-tenant.enums';
|
|
238
240
|
export * from './core/modules/tenant/core-tenant.guard';
|
|
241
|
+
export * from './core/modules/tenant/core-role-scope.registry';
|
|
239
242
|
export * from './core/modules/tenant/core-tenant.helpers';
|
|
240
243
|
export * from './core/modules/tenant/core-tenant.module';
|
|
241
244
|
export * from './core/modules/tenant/core-tenant.service';
|
|
@@ -98,12 +98,28 @@ export class FileService extends CoreFileService {
|
|
|
98
98
|
* See `src/core/modules/file/README.md` § Access control, and
|
|
99
99
|
* `tests/file-ownership.e2e-spec.ts` for the end-to-end contract test.
|
|
100
100
|
*/
|
|
101
|
+
/**
|
|
102
|
+
* NOTE FOR CONSUMERS: since 11.35.0 you may not need any of this.
|
|
103
|
+
*
|
|
104
|
+
* `file.access: 'owner'` is exactly the rule below, shipped by the framework — including the parts
|
|
105
|
+
* that are easy to get wrong (fail closed without a user, require the owner field to be PRESENT,
|
|
106
|
+
* cover the by-name branch, refuse a listing) and including the metadata stamping, which this project
|
|
107
|
+
* does by hand in `AvatarController`. `'tenant'` is the same rule against
|
|
108
|
+
* `metadata.tenantId` and the validated `RequestContext` tenant.
|
|
109
|
+
*
|
|
110
|
+
* This override stays because the reference server has to EXERCISE the seam — a rule that lives only
|
|
111
|
+
* in a preset proves the preset works, never that the inheritance point a consuming project extends
|
|
112
|
+
* still does. Keep it here; in your own project, prefer the preset unless your rights are something
|
|
113
|
+
* the framework cannot guess (an explicit read right, a case assignment, a published flag).
|
|
114
|
+
*/
|
|
101
115
|
protected override async checkRights(
|
|
102
116
|
input: any,
|
|
103
117
|
options?: FileServiceOptions & { checkInputType: FileInputCheckType },
|
|
104
118
|
): Promise<boolean> {
|
|
105
|
-
//
|
|
106
|
-
|
|
119
|
+
// Forced (system) calls and WRITES stay on the coarse role gate. Writes deliberately so: an
|
|
120
|
+
// upload has no owner to compare against yet — `AvatarController` records `metadata.ownerId` as
|
|
121
|
+
// it writes — so the only meaningful gate there is `file.uploadRoles`.
|
|
122
|
+
if (options?.force || options?.checkInputType === 'file' || options?.checkInputType === 'files') {
|
|
107
123
|
return true;
|
|
108
124
|
}
|
|
109
125
|
|
|
@@ -111,6 +127,33 @@ export class FileService extends CoreFileService {
|
|
|
111
127
|
return true;
|
|
112
128
|
}
|
|
113
129
|
|
|
130
|
+
// A LISTING cannot be narrowed by this hook, so it is refused rather than waved through.
|
|
131
|
+
//
|
|
132
|
+
// The hook is asked ONCE for the whole query, not once per row, so there is no answer here that
|
|
133
|
+
// means "…but only their own files". Returning `true` — which this rule used to do, and which is
|
|
134
|
+
// therefore the shape projects copied — hands a non-admin a full inventory of every upload the
|
|
135
|
+
// moment a project surfaces `findFileInfo()`: `CoreFileInfo` carries `filename`, `length`,
|
|
136
|
+
// `uploadDate` and the `id`, and for medical data the filename frequently IS the content.
|
|
137
|
+
//
|
|
138
|
+
// A project that wants "my files" FORCES the constraint server-side and passes `force: true`:
|
|
139
|
+
//
|
|
140
|
+
// this.fileService.findFileInfo(
|
|
141
|
+
// { filterQuery: { 'metadata.ownerId': String(currentUser.id) } },
|
|
142
|
+
// { force: true },
|
|
143
|
+
// );
|
|
144
|
+
//
|
|
145
|
+
// Note what that is NOT: it does not inspect the caller's `filterArgs` to check whether they are
|
|
146
|
+
// already narrowed. `filterArgs` is CLIENT-CONTROLLED, so approving a filter shape means
|
|
147
|
+
// validating attacker input — and any such check is one filter shape away from being wrong.
|
|
148
|
+
// Override the filter; never approve it. See tests/file-ownership.e2e-spec.ts.
|
|
149
|
+
// Explicit, even though falling through would ALSO deny: with `checkInputType: 'filterArgs'` the
|
|
150
|
+
// `input` is a FilterArgs object, so the by-name raw lookup below answers null and the comparison
|
|
151
|
+
// fails. That is an accident of the fall-through, not a decision — it would flip the moment a
|
|
152
|
+
// project's own raw lookup behaved differently for a non-string input. Say it outright instead.
|
|
153
|
+
if (options.checkInputType === 'filterArgs') {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
114
157
|
// The RAW document on purpose: the public getFileInfo() runs prepareOutput(), which
|
|
115
158
|
// strips `metadata` — the very field this decision rests on.
|
|
116
159
|
const raw =
|