@lenne.tech/nest-server 11.27.7 → 11.28.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.
Files changed (42) hide show
  1. package/.claude/rules/role-system.md +90 -0
  2. package/.claude/rules/versioning.md +17 -2
  3. package/FRAMEWORK-API.md +27 -1
  4. package/dist/core/common/decorators/restricted.decorator.js +11 -7
  5. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  6. package/dist/core/common/exceptions/access-denied.exception.d.ts +4 -0
  7. package/dist/core/common/exceptions/access-denied.exception.js +12 -0
  8. package/dist/core/common/exceptions/access-denied.exception.js.map +1 -0
  9. package/dist/core/common/helpers/input.helper.js +4 -2
  10. package/dist/core/common/helpers/input.helper.js.map +1 -1
  11. package/dist/core/common/helpers/service.helper.js +4 -3
  12. package/dist/core/common/helpers/service.helper.js.map +1 -1
  13. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  14. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  15. package/dist/core/modules/better-auth/better-auth-roles.guard.js +1 -1
  16. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  17. package/dist/core/modules/better-auth/core-better-auth.controller.js +1 -3
  18. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  19. package/dist/core/modules/tenant/core-tenant-member.model.js +3 -3
  20. package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -1
  21. package/dist/core/modules/tenant/core-tenant.guard.js +7 -6
  22. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/server/modules/user/user.service.js +1 -1
  27. package/dist/server/modules/user/user.service.js.map +1 -1
  28. package/dist/tsconfig.build.tsbuildinfo +1 -1
  29. package/docs/REQUEST-LIFECYCLE.md +39 -0
  30. package/migration-guides/11.27.7-to-11.28.0.md +350 -0
  31. package/package.json +1 -1
  32. package/src/core/common/decorators/restricted.decorator.ts +34 -12
  33. package/src/core/common/exceptions/access-denied.exception.ts +49 -0
  34. package/src/core/common/helpers/input.helper.ts +9 -4
  35. package/src/core/common/helpers/service.helper.ts +6 -4
  36. package/src/core/modules/auth/guards/roles.guard.ts +5 -4
  37. package/src/core/modules/better-auth/better-auth-roles.guard.ts +4 -2
  38. package/src/core/modules/better-auth/core-better-auth.controller.ts +5 -6
  39. package/src/core/modules/tenant/core-tenant-member.model.ts +8 -3
  40. package/src/core/modules/tenant/core-tenant.guard.ts +26 -11
  41. package/src/index.ts +1 -0
  42. package/src/server/modules/user/user.service.ts +3 -2
@@ -949,6 +949,41 @@ The `process()` method in `ModuleService` is the **primary** way to handle CRUD
949
949
  +---------------------------------------------------------------+
950
950
  ```
951
951
 
952
+ ### Status Codes: 401 vs 403 (v11.28.0+)
953
+
954
+ One policy across **all five** permission layers — the role guards, the tenant guard, `check()` /
955
+ `checkRights`, `checkRestricted()` (object and field level), and a model's `securityCheck()`:
956
+
957
+ | Situation | Status | Thrown by |
958
+ |-----------|--------|-----------|
959
+ | Requester is **not authenticated** | **401** `ErrorCode.UNAUTHORIZED` | guards, `accessDeniedException(undefined)` |
960
+ | Requester **is authenticated** but lacks a right | **403** `ErrorCode.ACCESS_DENIED` | guards, `accessDeniedException(user)` |
961
+ | Resource is locked via `S_NO_ONE` | **403**, always — even for anonymous requesters | guards, `check()` |
962
+
963
+ `S_NO_ONE` is 403 for everyone because authenticating can never unlock it; a 401 would tell the
964
+ client to retry after logging in, which is a lie.
965
+
966
+ **Why this matters:** SPA auth layers commonly treat 401 as "session expired" and clear the session
967
+ (the `@lenne.tech/nuxt-extensions` auth interceptor patches `$fetch`/`fetch` globally and does exactly
968
+ this). A permission error answered with 401 therefore logs the user out of the whole app. With this
969
+ policy a frontend may treat 401 as "session invalid" — with one exception:
970
+ `ErrorCode.EMAIL_VERIFICATION_REQUIRED` is a legitimate 401 (no session exists yet at sign-in) that
971
+ must **not** trigger a logout. Branch on the ErrorCode, not on the status alone.
972
+
973
+ **Writing new denial code:** use the exported factory rather than hand-rolling the decision. It
974
+ returns the **native** `ForbiddenException` / `UnauthorizedException`, so `instanceof` checks and
975
+ `@Catch(...)` filters in consuming projects keep working:
976
+
977
+ ```typescript
978
+ import { accessDeniedException } from '@lenne.tech/nest-server';
979
+
980
+ // In a service, a custom guard, or a model's securityCheck():
981
+ throw accessDeniedException(currentUser);
982
+ ```
983
+
984
+ See `src/core/common/exceptions/access-denied.exception.ts` and
985
+ `migration-guides/11.27.7-to-11.28.0.md`.
986
+
952
987
  ### Depth-Based Optimization (v11.23.0+)
953
988
 
954
989
  When `process()` is called from within another `process()` call (service cascades like A.create → B.create → C.create), steps 4–6 are **conditionally skipped** on inner calls to avoid redundant work:
@@ -1095,6 +1130,10 @@ Controls who can access a resolver/controller method. Evaluated by the RolesGuar
1095
1130
 
1096
1131
  Controls who can see or modify specific properties. Evaluated by `CheckResponseInterceptor` (output) and `checkRights()` (input).
1097
1132
 
1133
+ On **output** a denied field is silently removed (no exception). On **input** the request is
1134
+ rejected: **403** for an authenticated requester, **401** for an anonymous one, and **403 always**
1135
+ for `S_NO_ONE` — see [Status Codes: 401 vs 403](#status-codes-401-vs-403-v11280) above.
1136
+
1098
1137
  ```typescript
1099
1138
  export class User extends CorePersistenceModel {
1100
1139
  // Only admins or the user themselves can see the email
@@ -0,0 +1,350 @@
1
+ # Migration Guide: 11.27.7 → 11.28.0
2
+
3
+ > Coming from 11.27.6 or earlier? Read [11.27.6 → 11.27.7](./11.27.6-to-11.27.7.md) first — it is a
4
+ > separate startup-crash + cookie-security bugfix release with no overlap with the changes here.
5
+
6
+ ## Overview
7
+
8
+ | Category | Details |
9
+ |----------|---------|
10
+ | **Breaking Changes** | **(1)** Permission errors for **authenticated** users now return **403 Forbidden** instead of 401 Unauthorized. **(2)** `@Roles(S_NO_ONE)` / `@Restricted(S_NO_ONE)` now return **403 for every requester**, including unauthenticated ones (was 401). **(3)** The **error messages** of these denials changed from raw English strings to the translatable `ErrorCode.ACCESS_DENIED` / `ErrorCode.UNAUTHORIZED`. **(4)** `CoreTenantGuard` now returns **401** (was 403) when a request has no authenticated user. **(5)** `@Restricted(S_SELF)` / `@Restricted(S_CREATOR)` on **input** fields now actually enforce ownership (they were silently inert before) — a field that looked owner-restricted may become writable, so **audit them before upgrading**. |
11
+ | **New Features** | `accessDeniedException(user, message?)` — an exported factory that derives 401 or 403 from the requester's auth state. Use it in your own services, models and guards instead of hand-rolling the decision. |
12
+ | **Bugfixes** | **(a)** Status-code semantics per RFC 9110 across **all five** permission layers (role guards, tenant guard, `check()`, `checkRestricted()`, model `securityCheck()`), which previously contradicted each other. **(b)** `checkRestricted()` decided `S_SELF`/`S_CREATOR` ownership from the request **payload** instead of the persisted object — an authenticated attacker could unlock an owner-restricted input field on someone else's record. Now aligned with `check()`, which always read the persisted object. |
13
+ | **Migration Effort** | ~5–20 minutes. `pnpm update`, then **(a)** update any assertion or handler that matches on **status 401** for an authenticated permission error or on the **exact wording** of a permission-error message, and **(b)** audit every `@Restricted(S_SELF)` / `@Restricted(S_CREATOR)` on an **input** type (Breaking Change 5). |
14
+
15
+ ### Why
16
+
17
+ - **RFC 9110:** 401 means "authenticate yourself", 403 means "you are authenticated, but not
18
+ allowed". A permission error answered with 401 tells clients to re-authenticate, which cannot fix
19
+ the problem.
20
+ - **Frontend auto-logout:** SPA auth layers commonly treat 401 as "session expired" and clear the
21
+ session — the `@lenne.tech/nuxt-extensions` auth interceptor patches `$fetch`/`fetch` globally and
22
+ does exactly this. With the old behavior, an authenticated user who merely lacked a right was
23
+ logged out of the whole app.
24
+ - **Debuggability:** 401 logs no longer mix real authentication failures with permission failures.
25
+
26
+ ---
27
+
28
+ ## Quick Migration
29
+
30
+ ```bash
31
+ pnpm add @lenne.tech/nest-server@11.28.0
32
+ pnpm run build && pnpm test
33
+ ```
34
+
35
+ Then work through the five breaking changes below. If your project never asserts 401 on an
36
+ authenticated request, never matches on permission-error message text, and uses no
37
+ `@Restricted(S_SELF)` / `@Restricted(S_CREATOR)` on an input type, there is nothing to do — but
38
+ **verify that last point** (Breaking Change 5), it is the one with security impact.
39
+
40
+ ---
41
+
42
+ ## Breaking Change 1: 403 instead of 401 for authenticated permission errors
43
+
44
+ | Path | Before | After (authenticated) | After (unauthenticated) |
45
+ |------|--------|-----------------------|-------------------------|
46
+ | `check()` / `checkRights` (`input.helper.ts`) | 401 | **403** | 401 |
47
+ | `checkRestricted()`, object level (`restricted.decorator.ts`) | 401 | **403** | 401 |
48
+ | `checkRestricted()`, field level (`restricted.decorator.ts`) | 401 | **403** | 401 |
49
+ | Roles processing in `prepareInput` (`service.helper.ts`) | 401 | **403** | 401 |
50
+ | `CoreTenantMemberModel.securityCheck()` (`core-tenant-member.model.ts`) | 401 | **403** | 401 |
51
+
52
+ The decision key is `user.id`: a requester whose user object carries an id is authenticated and gets
53
+ 403; everyone else gets 401. A **falsy but present** id (`0`, `''`) counts as authenticated — relevant
54
+ if your project uses numeric user ids.
55
+
56
+ **Before:**
57
+ ```typescript
58
+ // e2e: an authenticated customer patches an admin-only field
59
+ await testHelper.rest(`/users/${customer.id}`, {
60
+ cookies: customer.token,
61
+ method: 'PATCH',
62
+ payload: { customerNumber: 'B-HACKED' },
63
+ statusCode: 401, // ← old: permission error surfaced as 401
64
+ });
65
+ ```
66
+
67
+ **After:**
68
+ ```typescript
69
+ await testHelper.rest(`/users/${customer.id}`, {
70
+ cookies: customer.token,
71
+ method: 'PATCH',
72
+ payload: { customerNumber: 'B-HACKED' },
73
+ statusCode: 403, // ← new: authenticated, but lacking rights
74
+ });
75
+ ```
76
+
77
+ ## Breaking Change 2: `S_NO_ONE` is now 403 for everyone
78
+
79
+ `S_NO_ONE` marks a resource as locked for **all** requesters, administrators included.
80
+ Authenticating can therefore never grant access, which makes 401 ("authenticate and retry") a lie
81
+ even for an anonymous requester. All four paths now agree on **403**:
82
+
83
+ | Path | Before | After |
84
+ |------|--------|-------|
85
+ | `RolesGuard` (`@Roles(S_NO_ONE)`) | 401 always | **403 always** |
86
+ | `BetterAuthRolesGuard` (`@Roles(S_NO_ONE)`) | 401 always | **403 always** |
87
+ | `CoreTenantGuard` (`@Roles(S_NO_ONE)`) | 403 always | 403 always (unchanged) |
88
+ | `check()` (`@Restricted(S_NO_ONE)`, e.g. on `password`) | 401 always | **403 always** |
89
+
90
+ Update any test asserting `statusCode: 401` on a locked endpoint or a `S_NO_ONE`-restricted field.
91
+
92
+ ## Breaking Change 3: error messages are now translatable `ErrorCode`s
93
+
94
+ The paths above previously returned raw English strings (`'Missing rights'`, `'No access'`,
95
+ `'The current user has no access rights for <field> of <Class>'`,
96
+ `'Current user not allowed setting roles: <roles>'`). They now return the same `ErrorCode`s the role
97
+ guards have always thrown:
98
+
99
+ | Requester | Message |
100
+ |-----------|---------|
101
+ | authenticated | `ErrorCode.ACCESS_DENIED` → `#LTNS_0101: Access denied - Insufficient permissions` |
102
+ | unauthenticated | `ErrorCode.UNAUTHORIZED` → `#LTNS_0100: Unauthorized - User is not logged in` |
103
+
104
+ Two reasons:
105
+
106
+ 1. **They are translatable now.** The frontend error-translation layer keys off the `#LTNS_xxxx:`
107
+ marker. The old raw strings carried no marker, so users saw untranslated English — while the
108
+ *same* 403 coming from a role guard was translated. The framework no longer emits two
109
+ incompatible 403 formats.
110
+ 2. **They no longer leak internals.** The old field-level message embedded the Input/Model class name
111
+ and the property name in the HTTP response. That detail is still available — it goes to the debug
112
+ log (`config.debug`) instead of to the client.
113
+
114
+ **Migration:** replace message assertions with `ErrorCode` comparisons.
115
+
116
+ ```typescript
117
+ // Before
118
+ expect(res.errors[0].message).toEqual('The current user has no access rights for roles of UserInput');
119
+
120
+ // After
121
+ import { ErrorCode } from '@lenne.tech/nest-server';
122
+ expect(res.errors[0].message).toEqual(ErrorCode.ACCESS_DENIED);
123
+ ```
124
+
125
+ If you relied on the class/field detail while debugging, enable `config.debug` and read the server log.
126
+
127
+ ## Breaking Change 4: `CoreTenantGuard` returns 401 when unauthenticated
128
+
129
+ `CoreTenantGuard` previously threw `ForbiddenException('Authentication required')` — a **403** for a
130
+ request with **no user at all**. That inverts the policy above: re-authenticating *is* the remedy
131
+ here, so the client must be told. All five sites now throw **401** (`ErrorCode.UNAUTHORIZED`).
132
+
133
+ This only surfaces when `CoreTenantGuard` runs without a role guard ahead of it; in the standard
134
+ chain `RolesGuard` / `BetterAuthRolesGuard` already answered `!user` with 401.
135
+
136
+ ## Breaking Change 5: `S_SELF` / `S_CREATOR` on input fields now actually enforce ownership
137
+
138
+ This is the one that needs a real audit before you upgrade.
139
+
140
+ `checkRestricted()` used to decide `S_SELF` / `S_CREATOR` from the **request payload** (`data`) rather
141
+ than from the persisted object. On the input path `data` is the caller-supplied DTO, so an
142
+ authenticated attacker could unlock an owner-restricted field on **someone else's** record just by
143
+ asserting ownership in the body — the service applies the input to the target it was called with, not
144
+ to the ids in the payload:
145
+
146
+ ```
147
+ PATCH /users/<victim> { "id": "<attacker>", "createdBy": "<attacker>", "<restricted field>": ... }
148
+ ```
149
+
150
+ Because `MapAndValidatePipe` strips `id`/`createdBy` from payloads (they are not `@UnifiedField`s),
151
+ the branch usually could not fire, and such fields were **effectively admin-only-or-denied**. The fix
152
+ reads ownership from `serviceOptions.dbObject` (as `check()` always did) — so `S_SELF`/`S_CREATOR` on
153
+ an input field **start working**. A field that *looked* owner-restricted may suddenly become writable.
154
+
155
+ ### The audit
156
+
157
+ Search for ownership roles on input types:
158
+
159
+ ```bash
160
+ grep -rn "S_SELF\|S_CREATOR" src/server/**/inputs/
161
+ ```
162
+
163
+ For each hit, decide what the field really means:
164
+
165
+ | You want… | Use |
166
+ |-----------|-----|
167
+ | "the user may edit their own record's field" | `S_SELF` — correct, and now enforced |
168
+ | "only an admin" (the common real case for `email`, `status`, `roles`) | `RoleEnum.ADMIN` — remove `S_CREATOR` |
169
+ | "the field is open to any logged-in user" | `S_USER` |
170
+
171
+ **Two traps:**
172
+
173
+ 1. **`S_CREATOR` is not "the user themselves".** `createdBy` is stamped by the audit plugin onto
174
+ whoever **created the record**. On a self-signup that is the user; in an **invite / admin-provisioning
175
+ flow it is the inviting admin, permanently**. `@Restricted(S_CREATOR)` on a user input therefore
176
+ grants the *inviter* write access to the invited user's fields — rarely the intent, and dangerous
177
+ on `email` (→ password reset).
178
+ 2. **A broader class-level role hides the field-level one.** `@Restricted(S_USER)` on the class
179
+ OR-merges with a field-level `S_SELF` (`mergeRoles` defaults to true), so `S_USER` alone already
180
+ grants the field — it was never owner-gated, and this change does not alter it. Such fields are
181
+ safe but misleading; consider dropping the redundant `S_SELF`.
182
+
183
+ ### Not affected
184
+
185
+ - `check()` (`input.helper.ts`) already read from `dbObject` — no behavior change there.
186
+ - **Output** filtering is unchanged: the object being checked *is* the persisted record.
187
+ - `create()` has no `dbObject`, so ownership cannot be established there — `S_SELF`/`S_CREATOR` deny,
188
+ exactly as `check()` already did. (A normal create DTO carries neither `id` nor `createdBy`, so this
189
+ matches the old behavior for honest callers.)
190
+
191
+ ---
192
+
193
+ ## What is NOT affected
194
+
195
+ - **All "requires auth" cases** (no token/session) — still 401. Only the `S_NO_ONE` case changed
196
+ (Breaking Change 2), because there authentication can never help.
197
+ - **Token errors** (`Invalid token`, `Token expired`, refresh-token flows) — still 401.
198
+ - **Sign-in with wrong credentials** — still 401.
199
+ - **Email verification at sign-in** (`ErrorCode.EMAIL_VERIFICATION_REQUIRED`) — still **401**, and
200
+ correctly so: the requester has no session yet, so they are not authenticated. Note for frontends:
201
+ this is a 401 that must **not** trigger the auto-logout flow — branch on the ErrorCode, not on the
202
+ status alone.
203
+ - **`instanceof` checks and `@Catch(...)` filters** — see below. This is deliberate.
204
+
205
+ ## `instanceof` and exception filters keep working
206
+
207
+ The framework throws the **native** `ForbiddenException` / `UnauthorizedException`, never a custom
208
+ subclass. Existing filters keep firing, and the REST error body (including the `name` field that
209
+ `HttpExceptionLogFilter` emits) is unchanged:
210
+
211
+ ```typescript
212
+ @Catch(ForbiddenException) // fires for authenticated permission errors
213
+ @Catch(UnauthorizedException) // fires for unauthenticated ones
214
+ ```
215
+
216
+ A `@Catch(UnauthorizedException)` filter that used to catch an authenticated permission error will no
217
+ longer see it — it is a `ForbiddenException` now. That is Breaking Change 1, and the fix is to handle
218
+ `ForbiddenException` as well. No filter silently stops matching because of a changed exception class.
219
+
220
+ ### Use the factory in your own code
221
+
222
+ ```typescript
223
+ import { accessDeniedException } from '@lenne.tech/nest-server';
224
+
225
+ // In a service, a model's securityCheck(), or a custom guard:
226
+ throw accessDeniedException(currentUser); // 403 if authenticated, else 401
227
+ throw accessDeniedException(currentUser, 'Custom message'); // same decision, custom message
228
+ ```
229
+
230
+ Prefer this over hand-rolling `user?.id ? new ForbiddenException() : new UnauthorizedException()` —
231
+ it keeps the codebase on one policy and handles falsy-but-present ids correctly.
232
+
233
+ ---
234
+
235
+ ## Detailed Migration Steps
236
+
237
+ ### Step 1: Update the package
238
+
239
+ ```bash
240
+ pnpm add @lenne.tech/nest-server@11.28.0
241
+ ```
242
+
243
+ ### Step 2: Find status-code expectations to update
244
+
245
+ The candidates are assertions that send a token/cookie **and** expect 401:
246
+
247
+ ```bash
248
+ # Backend tests
249
+ grep -rn "statusCode: 401" tests/
250
+ grep -rn "toEqual(401)\|toBe(401)" tests/
251
+
252
+ # Locked endpoints / fields (S_NO_ONE) — every one of these is 403 now
253
+ grep -rn "S_NO_ONE" src/ tests/
254
+ ```
255
+
256
+ ### Step 3: Find message assertions to update
257
+
258
+ ```bash
259
+ grep -rn "Missing rights\|No access\|no access rights for\|not allowed setting roles" src/ tests/
260
+ ```
261
+
262
+ Replace them with `ErrorCode.ACCESS_DENIED` / `ErrorCode.UNAUTHORIZED`.
263
+
264
+ ### Step 4: Find frontend 401 handlers
265
+
266
+ The auto-logout interceptor usually does **not** live in your own `src/`/`app/` — in lt projects it
267
+ ships with `@lenne.tech/nuxt-extensions` (`node_modules/`). Check your own branches too:
268
+
269
+ ```bash
270
+ grep -rn "=== 401\|status === 401\|statusCode === 401\|case 401\|\[401" src/ app/
271
+ grep -rn "onResponseError\|onError" app/plugins/ app/composables/
272
+ ```
273
+
274
+ A 403 must show an error message; only a 401 may clear the session. Remember that
275
+ `EMAIL_VERIFICATION_REQUIRED` is a 401 that must **not** log the user out — branch on the ErrorCode.
276
+
277
+ For GraphQL clients the status sits at `errors[0].extensions.originalError.statusCode`, and
278
+ `originalError.error` is `"Forbidden"` instead of `"Unauthorized"` for these cases.
279
+
280
+ ### Step 5: Audit `S_SELF` / `S_CREATOR` on input types (security)
281
+
282
+ This is the step with security impact — do not skip it. See Breaking Change 5 for the full reasoning.
283
+
284
+ ```bash
285
+ grep -rn "S_SELF\|S_CREATOR" src/server/**/inputs/
286
+ ```
287
+
288
+ For each hit, confirm the intent still holds now that the role is actually enforced:
289
+
290
+ - **`S_CREATOR` on a user-editable field** (e.g. `email`, `status`) — almost always wrong: in an
291
+ invite/admin-provisioning flow the *creator* is the inviting admin, not the user. Change to
292
+ `RoleEnum.ADMIN`.
293
+ - **`S_SELF` under a broader class-level role** (`@Restricted(S_USER)` on the class) — never gated
294
+ anything (OR-merge); safe, but drop the redundant role to avoid the next reader's confusion.
295
+ - **`S_SELF` as the only role** — now correctly enforces "owner only". Confirm that is what you want.
296
+
297
+ ### Step 6: Verify
298
+
299
+ ```bash
300
+ pnpm run build && pnpm test
301
+ ```
302
+
303
+ ---
304
+
305
+ ## Compatibility Notes
306
+
307
+ - Consumers of `@lenne.tech/nuxt-extensions`: version ≥ 1.8.4 additionally hardens the client auth
308
+ interceptor to verify the session before logging out on 401, so even backends still returning 401
309
+ for permission errors no longer cause wrongful logouts. Both changes are independent and
310
+ complementary.
311
+ - Projects with a **vendored core** adopt this change via the regular core sync
312
+ (`/lt-dev:backend:update-nest-server-core`).
313
+ - **Custom models that throw from `securityCheck()`** should switch to `accessDeniedException(user)`.
314
+ Otherwise they keep returning 401 to authenticated users and reintroduce the auto-logout bug in
315
+ your own code. `CoreTenantMemberModel` is the reference implementation.
316
+
317
+ ---
318
+
319
+ ## Troubleshooting
320
+
321
+ ### A test now fails with "expected 401, received 403"
322
+
323
+ The request is authenticated and the user lacks rights — or the resource is `S_NO_ONE`-locked. 403 is
324
+ the new, correct status. Update the assertion (Step 2).
325
+
326
+ ### A test now fails on the error message
327
+
328
+ Permission errors return `ErrorCode.ACCESS_DENIED` / `ErrorCode.UNAUTHORIZED` instead of raw English
329
+ (Breaking Change 3). Compare against the `ErrorCode` constant (Step 3).
330
+
331
+ ### My frontend no longer logs users out on permission errors
332
+
333
+ That is the intended behavior: a permission error does not invalidate the session. Show an error
334
+ message instead — the new messages carry the `#LTNS_xxxx:` marker, so `useLtErrorTranslation()`
335
+ resolves them. Real session expiry still yields 401 and still triggers the logout flow.
336
+
337
+ ### I lost the "no access rights for `<field>` of `<Class>`" detail
338
+
339
+ It is written to the debug log now rather than returned to the client (it exposed internal class and
340
+ property names in the HTTP response). Enable `config.debug` to see it server-side.
341
+
342
+ ---
343
+
344
+ ## References
345
+
346
+ - [RFC 9110 §15.5.2 (401 Unauthorized) / §15.5.4 (403 Forbidden)](https://www.rfc-editor.org/rfc/rfc9110)
347
+ - `src/core/common/exceptions/access-denied.exception.ts` — the factory and the policy it encodes
348
+ - `src/core/modules/auth/guards/roles.guard.ts` — the 401/403 pattern the service layer now mirrors
349
+ - `.claude/rules/role-system.md` — role system and status-code rules
350
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) — reference implementation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.27.7",
3
+ "version": "11.28.0",
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",
@@ -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(data, user)) ||
318
+ (roles.includes(RoleEnum.S_SELF) && equalIds(owner, user)) ||
302
319
  (roles.includes(RoleEnum.S_CREATOR) &&
303
- (('createdBy' in data && equalIds(data.createdBy, user)) ||
304
- (config.allowCreatorOfParent && !('createdBy' in data) && config.isCreatorOfParent))) ||
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
- // Throw error
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 new UnauthorizedException(`The current user has no access rights for ${data.constructor?.name}`);
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 data is user or user is creator of data (for nested plain objects)
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(data, user) || ('createdBy' in data ? equalIds(data.createdBy, user) : config.isCreatorOfParent);
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
- // Throw error
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 new UnauthorizedException(
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, UnauthorizedException } from '@nestjs/common';
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 UnauthorizedException('No access');
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
- throw new UnauthorizedException('Missing rights');
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 new UnauthorizedException('Missing roles of current user');
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
- throw new UnauthorizedException(`Current user not allowed setting roles: ${missingRoles}`);
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 UnauthorizedException(ErrorCode.UNAUTHORIZED);
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 UnauthorizedException(ErrorCode.UNAUTHORIZED);
299
+ throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
299
300
  }
300
301
 
301
302
  // Check roles