@lenne.tech/nest-server 11.27.6 → 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.
- package/.claude/rules/architecture.md +66 -0
- package/.claude/rules/better-auth.md +88 -2
- package/.claude/rules/package-management.md +23 -14
- package/.claude/rules/role-system.md +90 -0
- package/.claude/rules/versioning.md +17 -2
- package/CLAUDE.md +8 -2
- package/FRAMEWORK-API.md +27 -1
- package/dist/core/common/decorators/restricted.decorator.d.ts +4 -4
- package/dist/core/common/decorators/restricted.decorator.js +29 -26
- 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/clone.helper.d.ts +6 -0
- package/dist/core/common/helpers/clone.helper.js +67 -0
- package/dist/core/common/helpers/clone.helper.js.map +1 -0
- package/dist/core/common/helpers/db.helper.d.ts +2 -14
- package/dist/core/common/helpers/db.helper.js +13 -106
- package/dist/core/common/helpers/db.helper.js.map +1 -1
- package/dist/core/common/helpers/id.helper.d.ts +15 -0
- package/dist/core/common/helpers/id.helper.js +104 -0
- package/dist/core/common/helpers/id.helper.js.map +1 -0
- package/dist/core/common/helpers/input.helper.d.ts +1 -6
- package/dist/core/common/helpers/input.helper.js +15 -72
- 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/common/inputs/combined-filter.input.d.ts +1 -12
- package/dist/core/common/inputs/combined-filter.input.js +2 -51
- package/dist/core/common/inputs/combined-filter.input.js.map +1 -1
- package/dist/core/common/inputs/filter.input.d.ts +10 -1
- package/dist/core/common/inputs/filter.input.js +40 -5
- package/dist/core/common/inputs/filter.input.js.map +1 -1
- package/dist/core/common/services/config.service.js +5 -5
- package/dist/core/common/services/config.service.js.map +1 -1
- package/dist/core/modules/ai/core-ai.constants.d.ts +22 -0
- package/dist/core/modules/ai/core-ai.constants.js +26 -0
- package/dist/core/modules/ai/core-ai.constants.js.map +1 -0
- package/dist/core/modules/ai/index.d.ts +1 -0
- package/dist/core/modules/ai/index.js +1 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/interfaces/ai-interaction-record.interface.d.ts +17 -0
- package/dist/core/modules/ai/interfaces/ai-interaction-record.interface.js +3 -0
- package/dist/core/modules/ai/interfaces/ai-interaction-record.interface.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai-budget.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-budget.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-budget.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-connection-preference.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-connection-preference.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-connection-preference.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-connection.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-connection.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-connection.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-conversation.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-interaction.service.d.ts +2 -3
- package/dist/core/modules/ai/services/core-ai-interaction.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-interaction.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mode.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-mode.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-mode.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-prompt-hint.service.d.ts +1 -3
- package/dist/core/modules/ai/services/core-ai-prompt-hint.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-prompt-hint.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-prompt.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-prompt.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-prompt.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-slot.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-slot.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-slot.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-tool-grant.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-tool-grant.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-tool-grant.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-tool-policy.service.d.ts +1 -2
- package/dist/core/modules/ai/services/core-ai-tool-policy.service.js +7 -5
- package/dist/core/modules/ai/services/core-ai-tool-policy.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +2 -17
- package/dist/core/modules/ai/services/core-ai.service.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 +3 -3
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +6 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.constants.d.ts +3 -0
- package/dist/core/modules/better-auth/core-better-auth.constants.js +7 -0
- package/dist/core/modules/better-auth/core-better-auth.constants.js.map +1 -0
- 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/better-auth/core-better-auth.module.d.ts +1 -2
- package/dist/core/modules/better-auth/core-better-auth.module.js +23 -20
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.registry.d.ts +4 -0
- package/dist/core/modules/better-auth/core-better-auth.registry.js +16 -0
- package/dist/core/modules/better-auth/core-better-auth.registry.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +1 -2
- package/dist/core/modules/better-auth/core-better-auth.service.js +7 -6
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.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/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +5 -0
- package/dist/core/modules/tus/tus.constants.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -1
- package/dist/core/modules/tus/tus.module.js +8 -6
- package/dist/core/modules/tus/tus.module.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.6-to-11.27.7.md +374 -0
- package/migration-guides/11.27.7-to-11.28.0.md +350 -0
- package/package.json +6 -104
- package/src/core/common/decorators/restricted.decorator.ts +67 -20
- package/src/core/common/exceptions/access-denied.exception.ts +49 -0
- package/src/core/common/helpers/clone.helper.ts +110 -0
- package/src/core/common/helpers/db.helper.ts +14 -161
- package/src/core/common/helpers/id.helper.ts +198 -0
- package/src/core/common/helpers/input.helper.ts +21 -88
- package/src/core/common/helpers/service.helper.ts +6 -4
- package/src/core/common/inputs/combined-filter.input.ts +10 -57
- package/src/core/common/inputs/filter.input.ts +92 -1
- package/src/core/common/services/config.service.ts +4 -1
- package/src/core/modules/ai/core-ai.constants.ts +92 -0
- package/src/core/modules/ai/index.ts +1 -0
- package/src/core/modules/ai/interfaces/ai-interaction-record.interface.ts +34 -0
- package/src/core/modules/ai/services/core-ai-budget.service.ts +8 -4
- package/src/core/modules/ai/services/core-ai-connection-preference.service.ts +8 -4
- package/src/core/modules/ai/services/core-ai-connection.service.ts +5 -6
- package/src/core/modules/ai/services/core-ai-conversation.service.ts +5 -6
- package/src/core/modules/ai/services/core-ai-interaction.service.ts +6 -7
- package/src/core/modules/ai/services/core-ai-mode.service.ts +8 -2
- package/src/core/modules/ai/services/core-ai-prompt-hint.service.ts +7 -4
- package/src/core/modules/ai/services/core-ai-prompt.service.ts +8 -2
- package/src/core/modules/ai/services/core-ai-slot.service.ts +7 -4
- package/src/core/modules/ai/services/core-ai-tool-grant.service.ts +8 -4
- package/src/core/modules/ai/services/core-ai-tool-policy.service.ts +8 -4
- package/src/core/modules/ai/services/core-ai.service.ts +8 -11
- package/src/core/modules/auth/guards/roles.guard.ts +5 -4
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +15 -7
- package/src/core/modules/better-auth/better-auth.config.ts +16 -0
- package/src/core/modules/better-auth/core-better-auth.constants.ts +73 -0
- package/src/core/modules/better-auth/core-better-auth.controller.ts +5 -6
- package/src/core/modules/better-auth/core-better-auth.module.ts +34 -8
- package/src/core/modules/better-auth/core-better-auth.registry.ts +53 -0
- package/src/core/modules/better-auth/core-better-auth.service.ts +19 -12
- package/src/core/modules/better-auth/index.ts +9 -0
- 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/core/modules/tus/tus.constants.ts +25 -0
- package/src/core/modules/tus/tus.module.ts +7 -2
- package/src/index.ts +1 -0
- package/src/server/modules/user/user.service.ts +3 -2
|
@@ -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.
|
|
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",
|
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
"build:dev": "pnpm run build",
|
|
24
24
|
"c": "pnpm run check",
|
|
25
25
|
"check": "node scripts/check.mjs",
|
|
26
|
-
"check:raw": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
27
|
-
"check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
28
|
-
"check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
26
|
+
"check:raw": "pnpm audit && pnpm run format:check && pnpm run lint && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
27
|
+
"check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
28
|
+
"check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm run check:swc-tdz && pnpm test && pnpm run build && bash scripts/check-server-start.sh",
|
|
29
|
+
"check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
|
|
29
30
|
"cf": "pnpm run check:fix",
|
|
30
31
|
"cnaf": "pnpm run check:naf",
|
|
31
32
|
"docs": "pnpm run docs:ci && open http://127.0.0.1:8080/ && open ./public/index.html && compodoc -p tsconfig.json -s ",
|
|
@@ -183,104 +184,5 @@
|
|
|
183
184
|
"watch": {
|
|
184
185
|
"build:dev": "src"
|
|
185
186
|
},
|
|
186
|
-
"packageManager": "pnpm@
|
|
187
|
-
"pnpm": {
|
|
188
|
-
"//overrides": {
|
|
189
|
-
"axios@<1.16.0": "Security: Multiple CVEs in axios <1.15.2 (SSRF via NO_PROXY, prototype pollution, header injection, CRLF injection in form-data, XSRF leakage, auth bypass, response tampering, no_proxy IP alias bypass, streamed upload/response bypass, toFormData DoS, null byte injection) - transitive via @getbrevo/brevo and node-mailjet. Remove when @getbrevo/brevo and node-mailjet ship axios>=1.16.0",
|
|
190
|
-
"fast-uri@<3.1.2": "Security: host confusion (GHSA-3vp4-mxqf-vmh4) and path traversal (GHSA-q4mq-mw7v-xq57) in fast-uri <3.1.2 - transitive via @compodoc/compodoc>@angular-devkit/schematics>@angular-devkit/core>ajv",
|
|
191
|
-
"@babel/plugin-transform-modules-systemjs@>=7.12.0 <7.29.4": "Security: prototype pollution-based variable shadowing (GHSA-7p7r-hpq8-6w8q) generates unsafe code - transitive via @compodoc/compodoc>@babel/preset-env",
|
|
192
|
-
"minimatch@<3.1.5": "Security: RegExp DoS (GHSA-p8p7-x288-28g6) - transitive via @getbrevo/brevo>rewire>eslint",
|
|
193
|
-
"minimatch@>=9.0.0 <9.0.9": "Security: RegExp DoS - transitive via @nestjs/cli>@swc/cli",
|
|
194
|
-
"minimatch@>=10.0.0 <10.2.5": "Security: RegExp DoS - transitive via @nestjs/apollo>ts-morph>@ts-morph/common and nodemon",
|
|
195
|
-
"ajv@<6.14.0": "Security: prototype pollution - transitive via @getbrevo/brevo>rewire>eslint",
|
|
196
|
-
"ajv@>=7.0.0-alpha.0 <8.18.0": "Security: prototype pollution - transitive via @nestjs/cli>@angular-devkit",
|
|
197
|
-
"undici@>=7.0.0 <7.28.0": "Security: various CVEs <7.28.0 (incl. high) - transitive via @compodoc/compodoc>cheerio + better-auth",
|
|
198
|
-
"piscina@<4.9.3": "Security: high CVE in piscina <4.9.3 - transitive via the build/test toolchain",
|
|
199
|
-
"@babel/core@<7.29.6": "Security: @babel/core <7.29.6 advisory - transitive via @compodoc/compodoc>@babel/preset-env",
|
|
200
|
-
"handlebars@>=4.0.0 <4.7.9": "Security: prototype pollution (GHSA-q42p-pg8m-cqh6) - transitive via @compodoc/compodoc",
|
|
201
|
-
"brace-expansion@<1.1.13": "Security: RegExp DoS - transitive via eslint>minimatch",
|
|
202
|
-
"brace-expansion@>=4.0.0 <5.0.6": "Security: RegExp DoS - Large numeric range defeats brace expansion (GHSA-jxxr-4gwj-5jf2) - transitive via nodemon>minimatch and @ts-morph/common>minimatch and @compodoc/compodoc>glob>minimatch",
|
|
203
|
-
"picomatch@<2.3.2": "Security: ReDoS - transitive via @nestjs/graphql>fast-glob>micromatch and @compodoc/compodoc>chokidar",
|
|
204
|
-
"picomatch@>=4.0.0 <4.0.4": "Security: ReDoS - transitive via vitest and vite",
|
|
205
|
-
"path-to-regexp@>=8.0.0 <8.4.2": "Security: ReDoS (GHSA-rhx6-c78j-4q9w) - transitive via express>router",
|
|
206
|
-
"kysely@>=0.26.0 <0.28.17": "Security: JSON-path traversal injection via unsanitized input (SQL injection) - transitive via better-auth>@better-auth/passkey>@better-auth/core",
|
|
207
|
-
"@protobufjs/utf8@<=1.1.0": "Security: overlong UTF-8 decoding - transitive via @apollo/server>@apollo/usage-reporting-protobuf",
|
|
208
|
-
"ws@>=8.0.0 <8.21.0": "Security: Memory exhaustion DoS + uninitialized memory disclosure (GHSA-96hv-2xvq-fx4p) - transitive via @nestjs/graphql",
|
|
209
|
-
"ws@>=7.0.0 <7.5.11": "Security: Memory exhaustion DoS in ws 7.x (GHSA-96hv-2xvq-fx4p) - transitive via @nestjs/graphql>subscriptions-transport-ws",
|
|
210
|
-
"qs@>=6.11.1 <=6.15.1": "Security: remotely triggerable DoS in qs.stringify with comma-format arrays (GHSA-q8mj-m7cp-5q26) - transitive via @compodoc/compodoc>body-parser",
|
|
211
|
-
"lodash@>=4.0.0 <4.18.0": "Security: CVE in lodash@4.17.x - transitive via @nestjs/graphql. 4.18.1 is the latest patched version",
|
|
212
|
-
"defu@<=6.1.6": "Security: prototype pollution via __proto__ key - transitive via better-auth",
|
|
213
|
-
"follow-redirects@<=1.15.11": "Security: Custom Authentication Headers leak on cross-domain redirect (GHSA-r4q5-vmmm-2653) - transitive via axios>@getbrevo/brevo and axios>node-mailjet",
|
|
214
|
-
"uuid@<14.0.0": "Security: Missing buffer bounds check in v3/v5/v6 (GHSA-w5hq-g745-h8pq) - transitive via @compodoc/compodoc and @compodoc/compodoc>@compodoc/live-server>http-auth",
|
|
215
|
-
"postcss@<8.5.10": "Security: XSS via Unescaped </style> in CSS Stringify Output (GHSA-qx2v-qp2m-jg93) - transitive via vite. Remove when vite ships with postcss>=8.5.10",
|
|
216
|
-
"esbuild@>=0.17.0 <0.28.1": "Security: esbuild dev-server responds to any request origin, letting any website the developer visits read files it serves (GHSA-67mh-4wv8-2f99, moderate); pinned to the latest patched release to keep esbuild aligned across the toolchain - dev-only, transitive via vite>vitest and @nestjs build tooling. Remove when all consumers resolve esbuild>=0.28.1",
|
|
217
|
-
"form-data@<4.0.6": "Security: CRLF injection via unescaped multipart field names/filenames (GHSA-hmw2-7cc7-3qxx) - transitive via @getbrevo/brevo>axios and node-mailjet>axios",
|
|
218
|
-
"vite@>=8.0.0 <8.0.16": "Security: fs.deny bypass on Windows alternate paths + file read CVEs - transitive via better-auth>vitest",
|
|
219
|
-
"hono@<4.12.25": "Security: multiple CVEs <4.12.25 (prototype pollution, bodyLimit/Vary bypass, JWT NumericDate) - transitive via @nestjs/terminus>prisma>@prisma/dev",
|
|
220
|
-
"nodemailer@<9.0.1": "Security: email/header injection CVEs <9.0.1 - direct dependency",
|
|
221
|
-
"multer@<2.2.0": "Security: unhandled multipart errors / DoS <2.2.0 - transitive via @nestjs/platform-express",
|
|
222
|
-
"js-yaml@<4.2.0": "Security: special-character handling / prototype pollution (patched in 4.2.0; 4.1.2 was never published) - transitive via @nestjs/swagger",
|
|
223
|
-
"@xhmikosr/decompress@<11.1.3": "Security: archive extraction can create files/links outside the target directory (GHSA-mp2f-45pm-3cg9, critical) - transitive via @swc/cli>@xhmikosr/bin-wrapper>@xhmikosr/downloader; @swc/cli 0.8.1 is already the latest release and still resolves the vulnerable range, so an override is the only fix",
|
|
224
|
-
"morgan@<1.11.0": "Security: Log Forging via unneutralized control characters in :remote-user (GHSA-4vj7-5mj6-jm8m, moderate, patched in 1.11.0) - dev-only, transitive via @compodoc/compodoc>@compodoc/live-server>morgan"
|
|
225
|
-
},
|
|
226
|
-
"overrides": {
|
|
227
|
-
"axios@<1.16.0": "1.16.0",
|
|
228
|
-
"fast-uri@<3.1.2": "3.1.2",
|
|
229
|
-
"@babel/plugin-transform-modules-systemjs@>=7.12.0 <7.29.4": "7.29.4",
|
|
230
|
-
"minimatch@<3.1.5": "3.1.5",
|
|
231
|
-
"minimatch@>=9.0.0 <9.0.9": "9.0.9",
|
|
232
|
-
"minimatch@>=10.0.0 <10.2.5": "10.2.5",
|
|
233
|
-
"ajv@<6.14.0": "6.14.0",
|
|
234
|
-
"ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0",
|
|
235
|
-
"undici@>=7.0.0 <7.28.0": "7.28.0",
|
|
236
|
-
"piscina@<4.9.3": "4.9.3",
|
|
237
|
-
"@babel/core@<7.29.6": "7.29.6",
|
|
238
|
-
"handlebars@>=4.0.0 <4.7.9": "4.7.9",
|
|
239
|
-
"brace-expansion@<1.1.13": "1.1.13",
|
|
240
|
-
"brace-expansion@>=4.0.0 <5.0.6": "5.0.6",
|
|
241
|
-
"picomatch@<2.3.2": "2.3.2",
|
|
242
|
-
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
|
243
|
-
"path-to-regexp@>=8.0.0 <8.4.2": "8.4.2",
|
|
244
|
-
"kysely@>=0.26.0 <0.28.17": "0.28.17",
|
|
245
|
-
"@protobufjs/utf8@<=1.1.0": "1.1.1",
|
|
246
|
-
"ws@>=8.0.0 <8.21.0": "8.21.0",
|
|
247
|
-
"ws@>=7.0.0 <7.5.11": "7.5.11",
|
|
248
|
-
"qs@>=6.11.1 <=6.15.1": "6.15.2",
|
|
249
|
-
"lodash@>=4.0.0 <4.18.0": "4.18.1",
|
|
250
|
-
"defu@<=6.1.6": "6.1.7",
|
|
251
|
-
"follow-redirects@<=1.15.11": "1.16.0",
|
|
252
|
-
"uuid@<14.0.0": "14.0.0",
|
|
253
|
-
"postcss@<8.5.10": "8.5.12",
|
|
254
|
-
"esbuild@>=0.17.0 <0.28.1": "0.28.1",
|
|
255
|
-
"form-data@<4.0.6": "4.0.6",
|
|
256
|
-
"vite@>=8.0.0 <8.0.16": "8.0.16",
|
|
257
|
-
"hono@<4.12.25": "4.12.25",
|
|
258
|
-
"nodemailer@<9.0.1": "9.0.1",
|
|
259
|
-
"multer@<2.2.0": "2.2.0",
|
|
260
|
-
"js-yaml@<4.2.0": "4.2.0",
|
|
261
|
-
"@xhmikosr/decompress@<11.1.3": "11.1.3",
|
|
262
|
-
"morgan@<1.11.0": "1.11.0"
|
|
263
|
-
},
|
|
264
|
-
"//peerDependencyRules": "allowedVersions: deps lag behind our newer majors (graphql-upload wants @types/express@^4, the deprecated apollo playground plugin wants @apollo/server@^4) — both work with our v5. ignoreMissing: browser-only vis-network peers pulled in transitively via yuml-diagram (server-side UML generation never renders, so these are not needed).",
|
|
265
|
-
"peerDependencyRules": {
|
|
266
|
-
"allowedVersions": {
|
|
267
|
-
"@apollo/server": "5",
|
|
268
|
-
"@types/express": "5"
|
|
269
|
-
},
|
|
270
|
-
"ignoreMissing": [
|
|
271
|
-
"@egjs/hammerjs",
|
|
272
|
-
"keycharm",
|
|
273
|
-
"vis-data",
|
|
274
|
-
"vis-util"
|
|
275
|
-
]
|
|
276
|
-
},
|
|
277
|
-
"onlyBuiltDependencies": [
|
|
278
|
-
"bcrypt",
|
|
279
|
-
"@swc/core",
|
|
280
|
-
"esbuild",
|
|
281
|
-
"@nestjs/core",
|
|
282
|
-
"@compodoc/compodoc",
|
|
283
|
-
"@apollo/protobufjs"
|
|
284
|
-
]
|
|
285
|
-
}
|
|
187
|
+
"packageManager": "pnpm@11.13.0"
|
|
286
188
|
}
|
|
@@ -1,10 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* NOTE ON `export function` VS `export const` (do not "modernize" these to arrow functions).
|
|
3
|
+
*
|
|
4
|
+
* `Restricted`, `getRestricted` and `checkRestricted` are declared as FUNCTION DECLARATIONS on
|
|
5
|
+
* purpose. This file sits on an import cycle:
|
|
6
|
+
*
|
|
7
|
+
* restricted.decorator → helpers/db.helper → helpers/input.helper → restricted.decorator
|
|
8
|
+
*
|
|
9
|
+
* so when `input.helper`'s module body runs, THIS module is still mid-evaluation. A `const` arrow is
|
|
10
|
+
* a temporal-dead-zone binding: any evaluation-time read of it from `input.helper` (a top-level
|
|
11
|
+
* alias, an `@Restricted`-decorated class, `design:type` metadata) would throw under SWC → CommonJS:
|
|
12
|
+
*
|
|
13
|
+
* ReferenceError: Cannot access 'checkRestricted' before initialization
|
|
14
|
+
*
|
|
15
|
+
* Function declarations are HOISTED and fully initialized before any module body runs, so they are
|
|
16
|
+
* immune. `input.helper` today only calls `checkRestricted` from inside a function body, which is
|
|
17
|
+
* why nothing has crashed — but that is one careless top-level line away, in the file that drives
|
|
18
|
+
* field-level access control. The hoisting removes the hazard rather than relying on vigilance.
|
|
19
|
+
*
|
|
20
|
+
* The proper fix is to break the cycle (extract the ID helpers out of `db.helper` into a leaf), and
|
|
21
|
+
* that is worth doing. Until then, `pnpm run check:swc-tdz` is the mechanical guard.
|
|
22
|
+
* See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)".
|
|
23
|
+
*/
|
|
2
24
|
import 'reflect-metadata';
|
|
3
25
|
import _ = require('lodash');
|
|
4
26
|
|
|
5
27
|
import { ProcessType } from '../enums/process-type.enum';
|
|
6
28
|
import { RoleEnum } from '../enums/role.enum';
|
|
7
|
-
import {
|
|
29
|
+
import { accessDeniedException } from '../exceptions/access-denied.exception';
|
|
30
|
+
// Import from the id.helper LEAF, never from db.helper: db.helper imports input.helper, which
|
|
31
|
+
// imports this file back — that cycle is what the extraction removed. See id.helper's docblock.
|
|
32
|
+
import { equalIds, getIncludedIds } from '../helpers/id.helper';
|
|
8
33
|
import { RequestContext } from '../services/request-context.service';
|
|
9
34
|
import { RequireAtLeastOne } from '../types/required-at-least-one.type';
|
|
10
35
|
import { checkRoleAccess } from '../../modules/tenant/core-tenant.helpers';
|
|
@@ -40,9 +65,9 @@ export type RestrictedType = (
|
|
|
40
65
|
* properties of the processed item, which is specified by the value of `memberOf`
|
|
41
66
|
* Via processType the restriction can be set for input or output only
|
|
42
67
|
*/
|
|
43
|
-
export
|
|
68
|
+
export function Restricted(...rolesOrMember: RestrictedType): ClassDecorator & PropertyDecorator {
|
|
44
69
|
return Reflect.metadata(restrictedMetaKey, rolesOrMember);
|
|
45
|
-
}
|
|
70
|
+
}
|
|
46
71
|
|
|
47
72
|
/**
|
|
48
73
|
* Cache for Restricted metadata — decorators are static, metadata never changes at runtime.
|
|
@@ -60,7 +85,7 @@ const restrictedMetadataCache = new WeakMap<object, Map<string, RestrictedType>>
|
|
|
60
85
|
/**
|
|
61
86
|
* Get restricted data for (property of) object
|
|
62
87
|
*/
|
|
63
|
-
export
|
|
88
|
+
export function getRestricted(object: unknown, propertyKey?: string): RestrictedType {
|
|
64
89
|
if (!object) {
|
|
65
90
|
return null;
|
|
66
91
|
}
|
|
@@ -94,13 +119,13 @@ export const getRestricted = (object: unknown, propertyKey?: string): Restricted
|
|
|
94
119
|
|
|
95
120
|
classCache.set(cacheKey, metadata);
|
|
96
121
|
return metadata;
|
|
97
|
-
}
|
|
122
|
+
}
|
|
98
123
|
|
|
99
124
|
/**
|
|
100
125
|
* Check data for restricted properties (properties with `Restricted` decorator)
|
|
101
126
|
* For special Roles and member of group checking the dbObject must be set in options
|
|
102
127
|
*/
|
|
103
|
-
export
|
|
128
|
+
export function checkRestricted(
|
|
104
129
|
data: any,
|
|
105
130
|
user: {
|
|
106
131
|
emailVerified?: any;
|
|
@@ -125,7 +150,7 @@ export const checkRestricted = (
|
|
|
125
150
|
throwError?: boolean;
|
|
126
151
|
} = {},
|
|
127
152
|
processedObjects: WeakSet<object> = new WeakSet(),
|
|
128
|
-
)
|
|
153
|
+
) {
|
|
129
154
|
// Act like Roles handling: checkObjectItself = false & mergeRoles = true
|
|
130
155
|
// For Input: throwError = true
|
|
131
156
|
// For Output: throwError = false
|
|
@@ -268,15 +293,32 @@ export const checkRestricted = (
|
|
|
268
293
|
return false;
|
|
269
294
|
}
|
|
270
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
|
+
|
|
271
313
|
// Check access rights
|
|
272
314
|
if (
|
|
273
315
|
roles.includes(RoleEnum.S_EVERYONE) ||
|
|
274
316
|
user?.hasRole?.(roles) ||
|
|
275
317
|
(user?.id && roles.includes(RoleEnum.S_USER)) ||
|
|
276
|
-
(roles.includes(RoleEnum.S_SELF) && equalIds(
|
|
318
|
+
(roles.includes(RoleEnum.S_SELF) && equalIds(owner, user)) ||
|
|
277
319
|
(roles.includes(RoleEnum.S_CREATOR) &&
|
|
278
|
-
(('createdBy' in
|
|
279
|
-
(config.allowCreatorOfParent && !('createdBy' in
|
|
320
|
+
((owner && 'createdBy' in owner && equalIds(owner.createdBy, user)) ||
|
|
321
|
+
(config.allowCreatorOfParent && owner && !('createdBy' in owner) && config.isCreatorOfParent))) ||
|
|
280
322
|
(roles.includes(RoleEnum.S_VERIFIED) && (user?.verified || user?.verifiedAt || user?.emailVerified)) ||
|
|
281
323
|
(user?.id && checkRoleAccess(roles, user?.roles, RequestContext.get()?.tenantRole))
|
|
282
324
|
) {
|
|
@@ -340,9 +382,10 @@ export const checkRestricted = (
|
|
|
340
382
|
if (config.debug) {
|
|
341
383
|
console.debug(`The current user has no access rights for ${data.constructor?.name}`);
|
|
342
384
|
}
|
|
343
|
-
//
|
|
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.
|
|
344
387
|
if (config.throwError) {
|
|
345
|
-
throw
|
|
388
|
+
throw accessDeniedException(user);
|
|
346
389
|
}
|
|
347
390
|
return null;
|
|
348
391
|
}
|
|
@@ -368,9 +411,14 @@ export const checkRestricted = (
|
|
|
368
411
|
|
|
369
412
|
// Check rights
|
|
370
413
|
if (valid) {
|
|
371
|
-
// 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;
|
|
372
419
|
config.isCreatorOfParent =
|
|
373
|
-
equalIds(
|
|
420
|
+
equalIds(parent, user) ||
|
|
421
|
+
(parent && 'createdBy' in parent ? equalIds(parent.createdBy, user) : config.isCreatorOfParent);
|
|
374
422
|
|
|
375
423
|
// Check deep
|
|
376
424
|
data[propertyKey] = checkRestricted(data[propertyKey], user, config, processedObjects);
|
|
@@ -380,11 +428,10 @@ export const checkRestricted = (
|
|
|
380
428
|
`The current user has no access rights for ${propertyKey}${data.constructor?.name ? ` of ${data.constructor.name}` : ''}`,
|
|
381
429
|
);
|
|
382
430
|
}
|
|
383
|
-
//
|
|
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.
|
|
384
433
|
if (config.throwError) {
|
|
385
|
-
throw
|
|
386
|
-
`The current user has no access rights for ${propertyKey}${data.constructor?.name ? ` of ${data.constructor.name}` : ''}`,
|
|
387
|
-
);
|
|
434
|
+
throw accessDeniedException(user);
|
|
388
435
|
}
|
|
389
436
|
|
|
390
437
|
// Remove property
|
|
@@ -394,4 +441,4 @@ export const checkRestricted = (
|
|
|
394
441
|
|
|
395
442
|
// Return processed data
|
|
396
443
|
return data;
|
|
397
|
-
}
|
|
444
|
+
}
|