@lenne.tech/nest-server 11.27.7 → 11.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/role-system.md +90 -0
- package/.claude/rules/versioning.md +17 -2
- package/FRAMEWORK-API.md +27 -1
- package/dist/core/common/decorators/restricted.decorator.js +11 -7
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/exceptions/access-denied.exception.d.ts +4 -0
- package/dist/core/common/exceptions/access-denied.exception.js +12 -0
- package/dist/core/common/exceptions/access-denied.exception.js.map +1 -0
- package/dist/core/common/helpers/input.helper.js +4 -2
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/service.helper.js +4 -3
- package/dist/core/common/helpers/service.helper.js.map +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 +1 -1
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js +1 -3
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant-member.model.js +3 -3
- package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.js +7 -6
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/user/user.service.js +1 -1
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +39 -0
- package/migration-guides/11.27.7-to-11.28.0.md +350 -0
- package/migration-guides/11.28.0-to-11.28.1.md +155 -0
- package/package.json +24 -20
- package/src/core/common/decorators/restricted.decorator.ts +34 -12
- package/src/core/common/exceptions/access-denied.exception.ts +49 -0
- package/src/core/common/helpers/input.helper.ts +9 -4
- package/src/core/common/helpers/service.helper.ts +6 -4
- package/src/core/modules/auth/guards/roles.guard.ts +5 -4
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +4 -2
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +1 -1
- package/src/core/modules/better-auth/core-better-auth.controller.ts +5 -6
- package/src/core/modules/tenant/core-tenant-member.model.ts +8 -3
- package/src/core/modules/tenant/core-tenant.guard.ts +26 -11
- package/src/index.ts +1 -0
- package/src/server/modules/user/user.service.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.28.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",
|
|
@@ -84,41 +84,44 @@
|
|
|
84
84
|
"@getbrevo/brevo": "3.0.1",
|
|
85
85
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
86
86
|
"@nestjs/apollo": "13.4.2",
|
|
87
|
-
"@nestjs/common": "11.1.
|
|
88
|
-
"@nestjs/core": "11.1.
|
|
87
|
+
"@nestjs/common": "11.1.28",
|
|
88
|
+
"@nestjs/core": "11.1.28",
|
|
89
89
|
"@nestjs/graphql": "13.4.2",
|
|
90
90
|
"@nestjs/jwt": "11.0.2",
|
|
91
91
|
"@nestjs/mongoose": "11.0.4",
|
|
92
92
|
"@nestjs/passport": "11.0.5",
|
|
93
|
-
"@nestjs/platform-express": "11.1.
|
|
93
|
+
"@nestjs/platform-express": "11.1.28",
|
|
94
94
|
"@nestjs/schedule": "6.1.3",
|
|
95
95
|
"@nestjs/swagger": "11.4.2",
|
|
96
96
|
"@nestjs/terminus": "11.1.1",
|
|
97
|
-
"@nestjs/websockets": "11.1.
|
|
97
|
+
"@nestjs/websockets": "11.1.28",
|
|
98
98
|
"@tus/file-store": "2.1.0",
|
|
99
99
|
"@tus/server": "2.4.1",
|
|
100
|
-
"@types/supertest": "7.2.
|
|
100
|
+
"@types/supertest": "7.2.1",
|
|
101
101
|
"bcrypt": "6.0.0",
|
|
102
102
|
"better-auth": "1.6.23",
|
|
103
103
|
"class-transformer": "0.5.1",
|
|
104
104
|
"class-validator": "0.15.1",
|
|
105
105
|
"compression": "1.8.1",
|
|
106
106
|
"cookie-parser": "1.4.7",
|
|
107
|
+
"cron": "4.4.0",
|
|
107
108
|
"dotenv": "17.4.2",
|
|
108
109
|
"ejs": "5.0.2",
|
|
109
110
|
"express": "5.2.1",
|
|
110
111
|
"graphql": "16.14.0",
|
|
111
|
-
"graphql-query-complexity": "1.1.
|
|
112
|
+
"graphql-query-complexity": "1.1.1",
|
|
112
113
|
"graphql-subscriptions": "3.0.0",
|
|
113
114
|
"graphql-upload": "15.0.2",
|
|
115
|
+
"graphql-ws": "6.0.8",
|
|
116
|
+
"jose": "6.2.1",
|
|
114
117
|
"js-sha256": "0.11.1",
|
|
115
118
|
"json-to-graphql-query": "2.3.0",
|
|
116
119
|
"lodash": "4.18.1",
|
|
117
120
|
"mongodb": "7.2.0",
|
|
118
|
-
"mongoose": "9.
|
|
119
|
-
"multer": "2.
|
|
121
|
+
"mongoose": "9.7.4",
|
|
122
|
+
"multer": "2.2.0",
|
|
120
123
|
"node-mailjet": "6.0.11",
|
|
121
|
-
"nodemailer": "
|
|
124
|
+
"nodemailer": "9.0.3",
|
|
122
125
|
"passport": "0.7.0",
|
|
123
126
|
"passport-jwt": "4.0.1",
|
|
124
127
|
"reflect-metadata": "0.2.2",
|
|
@@ -126,26 +129,27 @@
|
|
|
126
129
|
"rxjs": "7.8.2",
|
|
127
130
|
"supertest": "7.2.2",
|
|
128
131
|
"ts-morph": "27.0.2",
|
|
132
|
+
"ws": "8.21.0",
|
|
129
133
|
"yuml-diagram": "1.2.0"
|
|
130
134
|
},
|
|
131
135
|
"devDependencies": {
|
|
132
136
|
"@compodoc/compodoc": "1.2.1",
|
|
133
137
|
"@nestjs/cli": "11.0.21",
|
|
134
138
|
"@nestjs/schematics": "11.1.0",
|
|
135
|
-
"@nestjs/testing": "11.1.
|
|
139
|
+
"@nestjs/testing": "11.1.28",
|
|
136
140
|
"@swc/cli": "0.8.1",
|
|
137
|
-
"@swc/core": "1.15.
|
|
141
|
+
"@swc/core": "1.15.43",
|
|
138
142
|
"@types/compression": "1.8.1",
|
|
139
143
|
"@types/cookie-parser": "1.4.10",
|
|
140
144
|
"@types/ejs": "3.1.5",
|
|
141
145
|
"@types/express": "5.0.6",
|
|
142
146
|
"@types/lodash": "4.17.24",
|
|
143
|
-
"@types/multer": "2.
|
|
147
|
+
"@types/multer": "2.2.0",
|
|
144
148
|
"@types/node": "25.9.1",
|
|
145
|
-
"@types/nodemailer": "8.0.
|
|
149
|
+
"@types/nodemailer": "8.0.1",
|
|
146
150
|
"@types/passport": "1.0.17",
|
|
147
|
-
"@vitest/coverage-v8": "4.1.
|
|
148
|
-
"@vitest/ui": "4.1.
|
|
151
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
152
|
+
"@vitest/ui": "4.1.10",
|
|
149
153
|
"ansi-colors": "4.1.3",
|
|
150
154
|
"find-file-up": "2.0.1",
|
|
151
155
|
"husky": "9.1.7",
|
|
@@ -153,17 +157,17 @@
|
|
|
153
157
|
"npm-watch": "0.13.0",
|
|
154
158
|
"otpauth": "9.5.1",
|
|
155
159
|
"oxfmt": "0.51.0",
|
|
156
|
-
"oxlint": "1.
|
|
160
|
+
"oxlint": "1.74.0",
|
|
157
161
|
"rimraf": "6.1.3",
|
|
158
162
|
"ts-node": "10.9.2",
|
|
159
163
|
"tsconfig-paths": "4.2.0",
|
|
160
|
-
"tsx": "4.
|
|
164
|
+
"tsx": "4.23.1",
|
|
161
165
|
"tus-js-client": "4.3.1",
|
|
162
166
|
"typescript": "5.9.3",
|
|
163
167
|
"unplugin-swc": "1.5.9",
|
|
164
|
-
"vite": "8.
|
|
168
|
+
"vite": "8.1.4",
|
|
165
169
|
"vite-plugin-node": "8.0.0",
|
|
166
|
-
"vitest": "4.1.
|
|
170
|
+
"vitest": "4.1.10"
|
|
167
171
|
},
|
|
168
172
|
"main": "dist/index.js",
|
|
169
173
|
"types": "dist/index.d.ts",
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
* that is worth doing. Until then, `pnpm run check:swc-tdz` is the mechanical guard.
|
|
22
22
|
* See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)".
|
|
23
23
|
*/
|
|
24
|
-
import { UnauthorizedException } from '@nestjs/common';
|
|
25
24
|
import 'reflect-metadata';
|
|
26
25
|
import _ = require('lodash');
|
|
27
26
|
|
|
28
27
|
import { ProcessType } from '../enums/process-type.enum';
|
|
29
28
|
import { RoleEnum } from '../enums/role.enum';
|
|
29
|
+
import { accessDeniedException } from '../exceptions/access-denied.exception';
|
|
30
30
|
// Import from the id.helper LEAF, never from db.helper: db.helper imports input.helper, which
|
|
31
31
|
// imports this file back — that cycle is what the extraction removed. See id.helper's docblock.
|
|
32
32
|
import { equalIds, getIncludedIds } from '../helpers/id.helper';
|
|
@@ -293,15 +293,32 @@ export function checkRestricted(
|
|
|
293
293
|
return false;
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
// Ownership (S_SELF, S_CREATOR) must be decided from the PERSISTED object, never from `data`.
|
|
297
|
+
//
|
|
298
|
+
// On the INPUT path `data` is the caller-supplied DTO, so every ownership claim in it is
|
|
299
|
+
// attacker-controlled. An authenticated attacker could otherwise unlock an owner-restricted
|
|
300
|
+
// field on someone ELSE's record just by asserting ownership in the payload — the service
|
|
301
|
+
// applies the input to the target it was called with, not to the ids in the body:
|
|
302
|
+
//
|
|
303
|
+
// PATCH /users/<victim> { "id": "<own-id>", "createdBy": "<own-id>", "<restricted>": ... }
|
|
304
|
+
//
|
|
305
|
+
// `check()` (input.helper.ts) already reads both from `config.dbObject`; this is the same
|
|
306
|
+
// check and must agree with it. Where no dbObject exists (e.g. create), ownership cannot be
|
|
307
|
+
// established — exactly what check() does too.
|
|
308
|
+
//
|
|
309
|
+
// On the OUTPUT path there is no attacker-controlled input: `data` IS the persisted object
|
|
310
|
+
// (and a list yields one `data` per item), so it stays the source of truth there.
|
|
311
|
+
const owner = config.processType === ProcessType.INPUT ? config.dbObject : data;
|
|
312
|
+
|
|
296
313
|
// Check access rights
|
|
297
314
|
if (
|
|
298
315
|
roles.includes(RoleEnum.S_EVERYONE) ||
|
|
299
316
|
user?.hasRole?.(roles) ||
|
|
300
317
|
(user?.id && roles.includes(RoleEnum.S_USER)) ||
|
|
301
|
-
(roles.includes(RoleEnum.S_SELF) && equalIds(
|
|
318
|
+
(roles.includes(RoleEnum.S_SELF) && equalIds(owner, user)) ||
|
|
302
319
|
(roles.includes(RoleEnum.S_CREATOR) &&
|
|
303
|
-
(('createdBy' in
|
|
304
|
-
(config.allowCreatorOfParent && !('createdBy' in
|
|
320
|
+
((owner && 'createdBy' in owner && equalIds(owner.createdBy, user)) ||
|
|
321
|
+
(config.allowCreatorOfParent && owner && !('createdBy' in owner) && config.isCreatorOfParent))) ||
|
|
305
322
|
(roles.includes(RoleEnum.S_VERIFIED) && (user?.verified || user?.verifiedAt || user?.emailVerified)) ||
|
|
306
323
|
(user?.id && checkRoleAccess(roles, user?.roles, RequestContext.get()?.tenantRole))
|
|
307
324
|
) {
|
|
@@ -365,9 +382,10 @@ export function checkRestricted(
|
|
|
365
382
|
if (config.debug) {
|
|
366
383
|
console.debug(`The current user has no access rights for ${data.constructor?.name}`);
|
|
367
384
|
}
|
|
368
|
-
//
|
|
385
|
+
// 403 when authenticated, 401 otherwise (see accessDeniedException). The class name stays in
|
|
386
|
+
// the debug log above — the client gets the translatable ErrorCode the role guards also use.
|
|
369
387
|
if (config.throwError) {
|
|
370
|
-
throw
|
|
388
|
+
throw accessDeniedException(user);
|
|
371
389
|
}
|
|
372
390
|
return null;
|
|
373
391
|
}
|
|
@@ -393,9 +411,14 @@ export function checkRestricted(
|
|
|
393
411
|
|
|
394
412
|
// Check rights
|
|
395
413
|
if (valid) {
|
|
396
|
-
// Check if
|
|
414
|
+
// Check if the parent is the user, or the user created it (for nested plain objects).
|
|
415
|
+
// Same rule as above: on INPUT the ownership claim must come from the persisted object, not
|
|
416
|
+
// from the DTO — otherwise a forged `id`/`createdBy` in the payload would propagate a faked
|
|
417
|
+
// "creator of parent" trust down into every nested object.
|
|
418
|
+
const parent = config.processType === ProcessType.INPUT ? config.dbObject : data;
|
|
397
419
|
config.isCreatorOfParent =
|
|
398
|
-
equalIds(
|
|
420
|
+
equalIds(parent, user) ||
|
|
421
|
+
(parent && 'createdBy' in parent ? equalIds(parent.createdBy, user) : config.isCreatorOfParent);
|
|
399
422
|
|
|
400
423
|
// Check deep
|
|
401
424
|
data[propertyKey] = checkRestricted(data[propertyKey], user, config, processedObjects);
|
|
@@ -405,11 +428,10 @@ export function checkRestricted(
|
|
|
405
428
|
`The current user has no access rights for ${propertyKey}${data.constructor?.name ? ` of ${data.constructor.name}` : ''}`,
|
|
406
429
|
);
|
|
407
430
|
}
|
|
408
|
-
//
|
|
431
|
+
// 403 when authenticated, 401 otherwise (see accessDeniedException). The field and class name
|
|
432
|
+
// stay in the debug log above — the client gets the translatable ErrorCode the guards also use.
|
|
409
433
|
if (config.throwError) {
|
|
410
|
-
throw
|
|
411
|
-
`The current user has no access rights for ${propertyKey}${data.constructor?.name ? ` of ${data.constructor.name}` : ''}`,
|
|
412
|
-
);
|
|
434
|
+
throw accessDeniedException(user);
|
|
413
435
|
}
|
|
414
436
|
|
|
415
437
|
// Remove property
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { ErrorCode } from '../../modules/error-code/error-codes';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates the access error that matches the requester's auth state (RFC 9110, mirrors RolesGuard):
|
|
7
|
+
* **403 Forbidden** for authenticated requesters (a permission problem) and **401 Unauthorized**
|
|
8
|
+
* only when the requester is not authenticated.
|
|
9
|
+
*
|
|
10
|
+
* Frontends commonly treat 401 as "session expired" and auto-logout (the `@lenne.tech/nuxt-extensions`
|
|
11
|
+
* auth interceptor patches `$fetch`/`fetch` globally and does exactly this), so a mere permission
|
|
12
|
+
* error must never surface as 401 — it would kick a logged-in user out of the whole app.
|
|
13
|
+
*
|
|
14
|
+
* This is a **factory, not a class**, on purpose: it returns the *native* Nest exceptions, so
|
|
15
|
+
* `instanceof ForbiddenException` / `instanceof UnauthorizedException` and `@Catch(...)` filters in
|
|
16
|
+
* consuming projects keep working, and the REST wire body (which `HttpExceptionLogFilter` builds via
|
|
17
|
+
* `{ ...exception }`, including `name`) stays identical to what it was before. A custom
|
|
18
|
+
* `HttpException` subclass would satisfy neither.
|
|
19
|
+
*
|
|
20
|
+
* The default messages are the same translatable `ErrorCode`s the role guards throw (`#LTNS_xxxx:`
|
|
21
|
+
* marker → resolvable by the frontend error-translation layer). Pass an explicit `message` only
|
|
22
|
+
* where a raw string is genuinely required; prefer logging request-specific detail (class names,
|
|
23
|
+
* field names) over returning it to the client.
|
|
24
|
+
*
|
|
25
|
+
* @param user The **requesting** user (never the target object). The decision key is `user.id`: an
|
|
26
|
+
* id that is present — even a falsy one like `0` or `''` — counts as authenticated, while
|
|
27
|
+
* `undefined`, `null` or no user at all does not. This mirrors how `check()` defines "logged in"
|
|
28
|
+
* (`S_USER` requires `user?.id`).
|
|
29
|
+
* @param message Overrides the default `ErrorCode`. Omit it to stay consistent with `RolesGuard`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* // 403 for an authenticated user who lacks a right, 401 for an anonymous requester:
|
|
33
|
+
* throw accessDeniedException(currentUser);
|
|
34
|
+
*
|
|
35
|
+
* @see src/core/modules/auth/guards/roles.guard.ts — the pre-existing 401/403 pattern this mirrors
|
|
36
|
+
* @see migration-guides/11.27.7-to-11.28.0.md
|
|
37
|
+
*/
|
|
38
|
+
export function accessDeniedException(
|
|
39
|
+
user: { id?: unknown } | null | undefined,
|
|
40
|
+
message?: string,
|
|
41
|
+
): ForbiddenException | UnauthorizedException {
|
|
42
|
+
// A present id means authenticated. `!!user?.id` would misjudge falsy-but-real ids (0, '') as
|
|
43
|
+
// anonymous and hand an authenticated user the very 401 this mechanism exists to avoid.
|
|
44
|
+
const authenticated = user?.id !== undefined && user?.id !== null;
|
|
45
|
+
|
|
46
|
+
return authenticated
|
|
47
|
+
? new ForbiddenException(message ?? ErrorCode.ACCESS_DENIED)
|
|
48
|
+
: new UnauthorizedException(message ?? ErrorCode.UNAUTHORIZED);
|
|
49
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BadRequestException,
|
|
1
|
+
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
|
2
2
|
import { plainToInstance } from 'class-transformer';
|
|
3
3
|
import { validate } from 'class-validator';
|
|
4
4
|
import { ValidatorOptions } from 'class-validator/types/validation/ValidatorOptions';
|
|
@@ -7,6 +7,8 @@ import { Kind } from 'graphql/index';
|
|
|
7
7
|
import { checkRestricted } from '../decorators/restricted.decorator';
|
|
8
8
|
import { ProcessType } from '../enums/process-type.enum';
|
|
9
9
|
import { RoleEnum } from '../enums/role.enum';
|
|
10
|
+
import { accessDeniedException } from '../exceptions/access-denied.exception';
|
|
11
|
+
import { ErrorCode } from '../../modules/error-code/error-codes';
|
|
10
12
|
import { clone } from './clone.helper';
|
|
11
13
|
import { merge } from './config.helper';
|
|
12
14
|
import { equalIds } from './id.helper';
|
|
@@ -248,9 +250,11 @@ export async function check(
|
|
|
248
250
|
}
|
|
249
251
|
let valid = false;
|
|
250
252
|
|
|
251
|
-
// Prevent access for everyone, including administrators
|
|
253
|
+
// Prevent access for everyone, including administrators. Always 403, never 401: the resource is
|
|
254
|
+
// locked permanently, so authenticating can never grant access and telling an anonymous
|
|
255
|
+
// requester to "authenticate and retry" (401) would be a lie. Both role guards do the same.
|
|
252
256
|
if (roles.includes(RoleEnum.S_NO_ONE)) {
|
|
253
|
-
throw new
|
|
257
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
254
258
|
}
|
|
255
259
|
|
|
256
260
|
// Check access
|
|
@@ -276,7 +280,8 @@ export async function check(
|
|
|
276
280
|
valid = true;
|
|
277
281
|
}
|
|
278
282
|
if (!valid) {
|
|
279
|
-
|
|
283
|
+
// 403 when authenticated, 401 otherwise — policy documented in accessDeniedException
|
|
284
|
+
throw accessDeniedException(user);
|
|
280
285
|
}
|
|
281
286
|
}
|
|
282
287
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { UnauthorizedException } from '@nestjs/common';
|
|
2
1
|
import bcrypt = require('bcrypt');
|
|
3
2
|
import { sha256 } from 'js-sha256';
|
|
4
3
|
import _ = require('lodash');
|
|
5
4
|
import { Types } from 'mongoose';
|
|
6
5
|
|
|
7
6
|
import { RoleEnum } from '../enums/role.enum';
|
|
7
|
+
import { accessDeniedException } from '../exceptions/access-denied.exception';
|
|
8
8
|
import { PrepareInputOptions } from '../interfaces/prepare-input-options.interface';
|
|
9
9
|
import { PrepareOutputOptions } from '../interfaces/prepare-output-options.interface';
|
|
10
10
|
import { ResolveSelector } from '../interfaces/resolve-selector.interface';
|
|
@@ -151,15 +151,17 @@ export async function prepareInput<T = any>(
|
|
|
151
151
|
value === undefined && delete input[key];
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Process roles
|
|
154
|
+
// Process roles — 403 when authenticated, 401 otherwise (see accessDeniedException). The rejected
|
|
155
|
+
// roles are logged rather than returned: the client gets the translatable ErrorCode the guards use.
|
|
155
156
|
if (config.checkRoles && (input as Record<string, any>).roles && !currentUser?.hasRole?.(RoleEnum.ADMIN)) {
|
|
156
157
|
if (!(currentUser as any)?.roles) {
|
|
157
|
-
throw
|
|
158
|
+
throw accessDeniedException(currentUser);
|
|
158
159
|
} else {
|
|
159
160
|
const allowedRoles = _.intersection((input as Record<string, any>).roles, (currentUser as any).roles);
|
|
160
161
|
if (allowedRoles.length !== (input as Record<string, any>).roles.length) {
|
|
161
162
|
const missingRoles = _.difference((input as Record<string, any>).roles, (currentUser as any).roles);
|
|
162
|
-
|
|
163
|
+
console.debug(`Current user not allowed setting roles: ${missingRoles}`);
|
|
164
|
+
throw accessDeniedException(currentUser);
|
|
163
165
|
}
|
|
164
166
|
(input as Record<string, any>).roles = allowedRoles;
|
|
165
167
|
}
|
|
@@ -137,9 +137,10 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
137
137
|
]);
|
|
138
138
|
const roles = mergeRolesMetadata(reflectorRoles);
|
|
139
139
|
|
|
140
|
-
// Check if locked - always deny
|
|
140
|
+
// Check if locked - always deny. 403, never 401: the endpoint is locked permanently, so
|
|
141
|
+
// authenticating can never grant access and a 401 ("authenticate and retry") would be a lie.
|
|
141
142
|
if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
|
|
142
|
-
throw new
|
|
143
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
// If no roles required, or S_EVERYONE is set, allow access without authentication
|
|
@@ -293,9 +294,9 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
|
|
|
293
294
|
]);
|
|
294
295
|
const roles = mergeRolesMetadata(reflectorRoles);
|
|
295
296
|
|
|
296
|
-
// Check if locked
|
|
297
|
+
// Check if locked — 403, never 401 (see canActivate: authenticating can never unlock it)
|
|
297
298
|
if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
|
|
298
|
-
throw new
|
|
299
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
// Check roles
|
|
@@ -88,9 +88,11 @@ export class BetterAuthRolesGuard implements CanActivate {
|
|
|
88
88
|
// Combine handler and class roles (handler takes precedence, like Reflector.getAll)
|
|
89
89
|
const roles = mergeRolesMetadata([handlerRoles, classRoles]);
|
|
90
90
|
|
|
91
|
-
// Check if locked - always deny
|
|
91
|
+
// Check if locked - always deny. 403, never 401: the endpoint is locked permanently, so
|
|
92
|
+
// authenticating can never grant access and a 401 ("authenticate and retry") would be a lie.
|
|
93
|
+
// Kept in sync with RolesGuard (see .claude/rules/better-auth.md).
|
|
92
94
|
if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
|
|
93
|
-
throw new
|
|
95
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
// If no roles required, or S_EVERYONE is set, allow access without authentication
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Controller,
|
|
6
6
|
Get,
|
|
7
7
|
HttpCode,
|
|
8
|
+
HttpException,
|
|
8
9
|
HttpStatus,
|
|
9
10
|
InternalServerErrorException,
|
|
10
11
|
Logger,
|
|
@@ -932,12 +933,10 @@ export class CoreBetterAuthController {
|
|
|
932
933
|
} catch (error) {
|
|
933
934
|
this.logger.error(`Better Auth handler error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
934
935
|
|
|
935
|
-
// Re-throw NestJS exceptions
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
error instanceof InternalServerErrorException
|
|
940
|
-
) {
|
|
936
|
+
// Re-throw NestJS exceptions. Matching HttpException (not an enumeration of subclasses) keeps
|
|
937
|
+
// any deliberately thrown status intact — an enumeration silently masks everything it forgets
|
|
938
|
+
// (e.g. a 403 from the rights checks) as a 500.
|
|
939
|
+
if (error instanceof HttpException) {
|
|
941
940
|
throw error;
|
|
942
941
|
}
|
|
943
942
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { UnauthorizedException } from '@nestjs/common';
|
|
2
1
|
import { ObjectType } from '@nestjs/graphql';
|
|
3
2
|
import { Schema } from '@nestjs/mongoose';
|
|
4
3
|
|
|
5
4
|
import { Restricted } from '../../common/decorators/restricted.decorator';
|
|
6
5
|
import { UnifiedField } from '../../common/decorators/unified-field.decorator';
|
|
7
6
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
7
|
+
import { accessDeniedException } from '../../common/exceptions/access-denied.exception';
|
|
8
8
|
import { CorePersistenceModel } from '../../common/models/core-persistence.model';
|
|
9
9
|
import { RequestContext } from '../../common/services/request-context.service';
|
|
10
10
|
import { DefaultHR, TenantMemberStatus } from './core-tenant.enums';
|
|
@@ -98,7 +98,11 @@ export class CoreTenantMemberModel extends CorePersistenceModel {
|
|
|
98
98
|
*/
|
|
99
99
|
override securityCheck(user: any, force?: boolean): this {
|
|
100
100
|
if (force) return this;
|
|
101
|
-
|
|
101
|
+
// accessDeniedException picks the status from the requester's auth state: 401 here (no user),
|
|
102
|
+
// 403 below (authenticated, but not owner / admin / tenant manager). Model-level denials must
|
|
103
|
+
// use this helper too — a permission error must never reach the client as a 401, or frontends
|
|
104
|
+
// treat it as an expired session and log the user out.
|
|
105
|
+
if (!user) throw accessDeniedException(user);
|
|
102
106
|
|
|
103
107
|
// Own membership or system admin
|
|
104
108
|
if (user.id === this.user || user.hasRole?.(RoleEnum.ADMIN)) return this;
|
|
@@ -116,6 +120,7 @@ export class CoreTenantMemberModel extends CorePersistenceModel {
|
|
|
116
120
|
return this;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
|
-
|
|
123
|
+
// Reached only when `user` is set (the `!user` case returned above), so this is a 403.
|
|
124
|
+
throw accessDeniedException(user);
|
|
120
125
|
}
|
|
121
126
|
}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CanActivate,
|
|
3
|
+
ExecutionContext,
|
|
4
|
+
ForbiddenException,
|
|
5
|
+
Injectable,
|
|
6
|
+
Logger,
|
|
7
|
+
OnModuleDestroy,
|
|
8
|
+
UnauthorizedException,
|
|
9
|
+
} from '@nestjs/common';
|
|
2
10
|
import { Reflector } from '@nestjs/core';
|
|
3
11
|
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
|
4
12
|
import { InjectModel } from '@nestjs/mongoose';
|
|
@@ -6,6 +14,7 @@ import { Model } from 'mongoose';
|
|
|
6
14
|
|
|
7
15
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
8
16
|
import { ConfigService } from '../../common/services/config.service';
|
|
17
|
+
import { ErrorCode } from '../error-code/error-codes';
|
|
9
18
|
import { CoreTenantMemberModel } from './core-tenant-member.model';
|
|
10
19
|
import { SKIP_TENANT_CHECK_KEY } from './core-tenant.decorators';
|
|
11
20
|
import { TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus } from './core-tenant.enums';
|
|
@@ -73,9 +82,14 @@ interface CachedTenantIds {
|
|
|
73
82
|
* 5. @SkipTenantCheck → role check against user.roles, no tenant context
|
|
74
83
|
* 6. BetterAuth auto-skip (betterAuth.skipTenantCheck config + no header) → skip, no tenant context
|
|
75
84
|
*
|
|
85
|
+
* Status codes (RFC 9110, aligned with RolesGuard since v11.28.0):
|
|
86
|
+
* - Missing authentication → 401 (re-authenticating IS the remedy, so the client must be told to)
|
|
87
|
+
* - Authenticated but lacking a right / membership → 403
|
|
88
|
+
* - S_NO_ONE → always 403 (authenticating can never unlock it)
|
|
89
|
+
*
|
|
76
90
|
* HEADER PRESENT:
|
|
77
91
|
* - System ADMIN (adminBypass: true) → set req.tenantId + isAdminBypass
|
|
78
|
-
* - No user →
|
|
92
|
+
* - No user → 401 (tenant access requires authentication)
|
|
79
93
|
* - Authenticated non-admin user:
|
|
80
94
|
* - Active member → checkRoleAccess against membership.role → set req.tenantId + tenantRole
|
|
81
95
|
* - Not active member → ALWAYS 403
|
|
@@ -86,7 +100,7 @@ interface CachedTenantIds {
|
|
|
86
100
|
* → resolveUserTenantIds with minLevel filter
|
|
87
101
|
* - Authenticated + no checkable roles → resolveUserTenantIds (all memberships)
|
|
88
102
|
* - No user + no checkable roles → pass (plugin safety net catches tenantId-schema access)
|
|
89
|
-
* - No user + checkable roles →
|
|
103
|
+
* - No user + checkable roles → 401 (authentication required)
|
|
90
104
|
*/
|
|
91
105
|
@Injectable()
|
|
92
106
|
export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
@@ -216,8 +230,9 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
216
230
|
|
|
217
231
|
// Defense-in-depth: S_NO_ONE is normally caught by RolesGuard/BetterAuthRolesGuard upstream,
|
|
218
232
|
// but guard it here too in case CoreTenantGuard runs standalone (e.g., custom guard chains).
|
|
233
|
+
// Always 403 — the same code the role guards return since v11.28.0.
|
|
219
234
|
if (roles.includes(RoleEnum.S_NO_ONE)) {
|
|
220
|
-
throw new ForbiddenException(
|
|
235
|
+
throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
|
|
221
236
|
}
|
|
222
237
|
|
|
223
238
|
const sEveryoneGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_EVERYONE);
|
|
@@ -258,7 +273,7 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
258
273
|
const sUserGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_USER);
|
|
259
274
|
if (sUserGrantsAccess) {
|
|
260
275
|
if (!user) {
|
|
261
|
-
throw new
|
|
276
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
262
277
|
}
|
|
263
278
|
if (headerTenantId && !hasSkipDecorator) {
|
|
264
279
|
return this.handleSystemRoleWithTenantHeader(user, headerTenantId, request, isAdmin);
|
|
@@ -276,7 +291,7 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
276
291
|
const sVerifiedGrantsAccess: boolean = systemCheckRoles.includes(RoleEnum.S_VERIFIED);
|
|
277
292
|
if (sVerifiedGrantsAccess) {
|
|
278
293
|
if (!user) {
|
|
279
|
-
throw new
|
|
294
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
280
295
|
}
|
|
281
296
|
const isVerified = !!(user.verified || user.verifiedAt || user.emailVerified);
|
|
282
297
|
if (!isVerified) {
|
|
@@ -337,9 +352,9 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
337
352
|
return true;
|
|
338
353
|
}
|
|
339
354
|
|
|
340
|
-
// No user + header →
|
|
355
|
+
// No user + header → 401 (tenant access requires authentication; re-auth is the remedy)
|
|
341
356
|
if (!user) {
|
|
342
|
-
throw new
|
|
357
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
343
358
|
}
|
|
344
359
|
|
|
345
360
|
// Authenticated non-admin user: MUST be active member
|
|
@@ -375,9 +390,9 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
375
390
|
|
|
376
391
|
// Checkable roles present
|
|
377
392
|
if (checkableRoles.length > 0) {
|
|
378
|
-
// No user + roles required → 403
|
|
393
|
+
// No user + roles required → 401 (re-auth is the remedy, so it must not be a 403)
|
|
379
394
|
if (!user) {
|
|
380
|
-
throw new
|
|
395
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
381
396
|
}
|
|
382
397
|
|
|
383
398
|
// Check role access against user.roles (hierarchy: level comparison, normal: exact match)
|
|
@@ -604,7 +619,7 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
604
619
|
if (checkableRoles.length > 0) {
|
|
605
620
|
// Defense-in-depth: reject unauthenticated access even if RolesGuard is absent
|
|
606
621
|
if (!user) {
|
|
607
|
-
throw new
|
|
622
|
+
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
|
|
608
623
|
}
|
|
609
624
|
if (!isAdmin && !checkRoleAccess(checkableRoles, user.roles, undefined)) {
|
|
610
625
|
throw new ForbiddenException('Insufficient role');
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export * from './core/common/enums/logical-operator.enum';
|
|
|
26
26
|
export * from './core/common/enums/process-type.enum';
|
|
27
27
|
export * from './core/common/enums/role.enum';
|
|
28
28
|
export * from './core/common/enums/sort-order.emum';
|
|
29
|
+
export * from './core/common/exceptions/access-denied.exception';
|
|
29
30
|
export * from './core/common/filters/http-exception-log.filter';
|
|
30
31
|
export * from './core/common/helpers/common.helper';
|
|
31
32
|
export * from './core/common/helpers/config.helper';
|
|
@@ -98,9 +98,10 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
|
|
|
98
98
|
*/
|
|
99
99
|
async setAvatar(file: Express.Multer.File, user: User): Promise<string> {
|
|
100
100
|
const dbUser = await this.mainDbModel.findOne({ id: user.id }).exec();
|
|
101
|
-
// Check user
|
|
101
|
+
// Check user: the token is valid but the account no longer exists, so the session really is
|
|
102
|
+
// invalid — 401 is right here. (A permission error would have to be 403, see accessDeniedException.)
|
|
102
103
|
if (!dbUser) {
|
|
103
|
-
throw new UnauthorizedException('User
|
|
104
|
+
throw new UnauthorizedException('User of the current session no longer exists');
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
// Check file
|