@lenne.tech/nest-server 11.41.8 → 11.42.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/configurable-features.md +4 -3
- package/.claude/rules/role-system.md +31 -0
- package/.claude/rules/testing.md +4 -4
- package/CLAUDE.md +6 -0
- package/FRAMEWORK-API.md +3 -2
- package/dist/core/common/helpers/graphql-ws-context.helper.js +1 -1
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
- package/dist/core/common/helpers/outbound-url.helper.d.ts +14 -0
- package/dist/core/common/helpers/outbound-url.helper.js +234 -0
- package/dist/core/common/helpers/outbound-url.helper.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
- package/dist/core/common/middleware/request-context.middleware.js +1 -0
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
- package/dist/core/common/services/request-context.service.d.ts +1 -0
- package/dist/core/common/services/request-context.service.js.map +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.js +5 -2
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.js +3 -2
- package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
- package/dist/core/modules/ai/tools/ai-tool.registry.js +15 -2
- package/dist/core/modules/ai/tools/ai-tool.registry.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant-role.helper.d.ts +2 -0
- package/dist/core/modules/tenant/core-tenant-role.helper.js +36 -0
- package/dist/core/modules/tenant/core-tenant-role.helper.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +7 -1
- package/dist/core/modules/tenant/core-tenant.guard.js +56 -4
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +11 -0
- package/dist/core/modules/tus/core-tus.service.js +54 -14
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +2 -1
- package/dist/core/modules/tus/tus.constants.js.map +1 -1
- package/dist/core/modules/tus/tus.module.d.ts +2 -0
- package/dist/core/modules/tus/tus.module.js +14 -8
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +1 -0
- package/dist/core/modules/user/core-user.service.js +24 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +5 -3
- package/migration-guides/11.41.8-to-11.41.9.md +116 -0
- package/migration-guides/11.41.x-to-11.42.x.md +161 -0
- package/migration-guides/11.9.x-to-11.10.x.md +3 -2
- package/package.json +2 -1
- package/src/core/common/helpers/graphql-ws-context.helper.ts +1 -1
- package/src/core/common/helpers/outbound-url.helper.ts +310 -0
- package/src/core/common/interfaces/server-options.interface.ts +54 -0
- package/src/core/common/middleware/request-context.middleware.ts +1 -0
- package/src/core/common/services/request-context.service.ts +9 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +22 -3
- package/src/core/modules/ai/services/core-ai-mcp.service.ts +9 -3
- package/src/core/modules/ai/tools/ai-tool.registry.ts +33 -3
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +18 -11
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +14 -0
- package/src/core/modules/tenant/README.md +80 -8
- package/src/core/modules/tenant/core-tenant-role.helper.ts +75 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +78 -5
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +38 -11
- package/src/core/modules/tus/README.md +88 -14
- package/src/core/modules/tus/core-tus.service.ts +125 -30
- package/src/core/modules/tus/tus.constants.ts +12 -0
- package/src/core/modules/tus/tus.module.ts +41 -8
- package/src/core/modules/user/core-user.service.ts +53 -0
- package/src/index.ts +2 -0
|
@@ -161,6 +161,20 @@ betterAuth: {
|
|
|
161
161
|
|
|
162
162
|
See [Tenant README — BetterAuth Integration](./README.md#betterauth-iam-integration) for details.
|
|
163
163
|
|
|
164
|
+
### 11. Deactivated Tenants and Service-Level Roles (since 11.42.0)
|
|
165
|
+
|
|
166
|
+
- **Can a tenant be switched off in your project?** Set `multiTenancy.isTenantActive` in
|
|
167
|
+
`config.env.ts` and call `tenantGuard.invalidateTenant(tenantId)` after switching. WHY: the guard
|
|
168
|
+
checks the membership only; without the hook a deactivated tenant keeps working for its members,
|
|
169
|
+
its API tokens and every request without a tenant header.
|
|
170
|
+
- **Does a service enforce a tenant role that a controller also demands?** Call
|
|
171
|
+
`assertTenantRole(...)` in the service. WHY: MCP and AI tools call the service directly and never
|
|
172
|
+
pass the controller's `@Roles()`.
|
|
173
|
+
- **Running with `adminBypass: false`?** Platform administrators can no longer set a tenant account's
|
|
174
|
+
e-mail address or password through `updateUser`; support goes through the password-reset flow.
|
|
175
|
+
|
|
176
|
+
See the module README: "Deactivated Tenants" and "Service-Level Role Checks".
|
|
177
|
+
|
|
164
178
|
## Verification Checklist
|
|
165
179
|
|
|
166
180
|
- [ ] `pnpm run build` succeeds
|
|
@@ -211,15 +211,87 @@ It also suppresses tenant membership validation for `S_USER`/`S_VERIFIED` system
|
|
|
211
211
|
System admins (`RoleEnum.ADMIN`) bypass the membership check by default.
|
|
212
212
|
Disable with `multiTenancy: { adminBypass: false }`.
|
|
213
213
|
|
|
214
|
+
`adminBypass: false` promises that a platform administrator has no way into a tenant. Since 11.42.0
|
|
215
|
+
two paths that went around that promise follow it too:
|
|
216
|
+
|
|
217
|
+
- **Credentials:** `CoreUserService.update()` refuses a change of another account's e-mail address or
|
|
218
|
+
password (403) — for any caller, including the account's creator and other administrators. Exempt:
|
|
219
|
+
the account itself, profile fields, an unchanged address sent along, and system work without a
|
|
220
|
+
current user (invitation, password reset). Administrator TARGETS are deliberately not exempt: an
|
|
221
|
+
administrator may write `roles`, so promote-then-change would open every account. Override
|
|
222
|
+
`assertCredentialChangeAllowed()` to adjust.
|
|
223
|
+
- **AI tools** (chat and MCP): no ADMIN bypass in the tool registry; tenant-role tools need a
|
|
224
|
+
membership role like anyone's.
|
|
225
|
+
|
|
226
|
+
### Deactivated Tenants (`isTenantActive`, since 11.42.0)
|
|
227
|
+
|
|
228
|
+
The guard validates a membership and nothing else, so a tenant a project switched off keeps working —
|
|
229
|
+
for members, API tokens and the header-less tenant list — unless the project tells the guard:
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
// config.env.ts
|
|
233
|
+
multiTenancy: {
|
|
234
|
+
isTenantActive: async (tenantId, { connection }) =>
|
|
235
|
+
(await connection.collection('tenants').findOne({ _id: new Types.ObjectId(tenantId) }))?.active !== false,
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
| Path | Deactivated tenant |
|
|
240
|
+
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
241
|
+
| Header + tenant role, or `S_USER` / `S_VERIFIED` with a header | **403** `Tenant is inactive` |
|
|
242
|
+
| Tenant API token | **403** |
|
|
243
|
+
| `S_EVERYONE` with a header | not blocked, but no tenant context is set |
|
|
244
|
+
| No header | dropped from the user's tenant list — its data stays out of reach |
|
|
245
|
+
| GraphQL over WebSocket | no tenant resolved for a new subscription — an open one keeps its tenant until it reconnects (as after a removed membership) |
|
|
246
|
+
| Admin under `adminBypass` | still allowed — somebody has to be able to switch it back on |
|
|
247
|
+
|
|
248
|
+
The hook is asked **after** the membership check, so a non-member learns nothing about a tenant's
|
|
249
|
+
state. Return `false` for inactive; anything else counts as active. Answers are cached for
|
|
250
|
+
`cacheTtlMs`; call `tenantGuard.invalidateTenant(tenantId)` right after switching a tenant on or off
|
|
251
|
+
(broadcast to every replica with Redis). A function cannot come through `NSC__*` — set it in
|
|
252
|
+
`config.env.ts`. A custom guard can override the protected `isTenantActive(tenantId)` instead.
|
|
253
|
+
|
|
254
|
+
### Service-Level Role Checks (`assertTenantRole`, since 11.42.0)
|
|
255
|
+
|
|
256
|
+
`@Roles()` protects a controller or resolver method — the route, not the operation. MCP tools, AI tools,
|
|
257
|
+
queue processors and other services call the service directly and never pass that route. Enforce a
|
|
258
|
+
tenant role in the service itself to close every entry point at once:
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
import { assertTenantRole, DefaultHR } from '@lenne.tech/nest-server';
|
|
262
|
+
|
|
263
|
+
async updateAiModels(input: AiModelsInput, serviceOptions?: ServiceOptions) {
|
|
264
|
+
assertTenantRole(DefaultHR.OWNER, 'Only owners may change the AI models');
|
|
265
|
+
// …
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Decided like the guard decides `@Roles()` on the header path: `adminBypass` passes, a global role
|
|
270
|
+
answers from `user.roles`, a tenant role from the request's membership role (by hierarchy; without
|
|
271
|
+
tenant context it fails). Several roles are alternatives. `hasTenantRole(...roles)` answers the same
|
|
272
|
+
question as a boolean.
|
|
273
|
+
|
|
274
|
+
| Caller | Result |
|
|
275
|
+
| ------------------------------------------------------------------------------------------ | ------- |
|
|
276
|
+
| Signed in, role held | passes |
|
|
277
|
+
| Signed in, role not held | **403** |
|
|
278
|
+
| Anonymous client request (public route) | **401** |
|
|
279
|
+
| System work — no request context, or one no client request created (cron, migration, seed) | passes |
|
|
280
|
+
|
|
281
|
+
The last two rows are told apart by `RequestContext.fromRequest`, which `RequestContextMiddleware` and
|
|
282
|
+
the GraphQL WebSocket context set. Treating "no current user" alone as system work would wave the
|
|
283
|
+
anonymous caller of a public route through.
|
|
284
|
+
|
|
214
285
|
### Filtering Without Header
|
|
215
286
|
|
|
216
|
-
| User State
|
|
217
|
-
|
|
|
218
|
-
| Not authenticated, no context
|
|
219
|
-
| Authenticated, no memberships
|
|
220
|
-
| Authenticated, has memberships
|
|
221
|
-
| Authenticated + hierarchy role
|
|
222
|
-
| Admin without header
|
|
287
|
+
| User State | Filter Applied |
|
|
288
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------- |
|
|
289
|
+
| Not authenticated, no context | Safety Net: `ForbiddenException` on tenantId-schemas |
|
|
290
|
+
| Authenticated, no memberships | Safety Net: `ForbiddenException` on tenantId-schemas |
|
|
291
|
+
| Authenticated, has memberships | `{ tenantId: { $in: [user's tenant IDs] } }` |
|
|
292
|
+
| Authenticated + hierarchy role | `{ tenantId: { $in: [qualified tenant IDs] } }` (filtered by level) |
|
|
293
|
+
| Admin without header | No filter (sees all data via `isAdminBypass`) |
|
|
294
|
+
| Tenant deactivated (`isTenantActive` returns `false`) | Dropped from the user's tenant IDs (11.42.0+) |
|
|
223
295
|
|
|
224
296
|
## Extending via Module Inheritance
|
|
225
297
|
|
|
@@ -290,7 +362,7 @@ Both are **instance** methods on the singleton `CoreTenantGuard` — inject it a
|
|
|
290
362
|
Mechanism, for anyone extending the guard (`core-tenant.guard.ts`):
|
|
291
363
|
|
|
292
364
|
- The channel is namespaced with the framework's Redis key prefix, so two applications sharing one Redis instance do not clear each other's caches. Two **stages** of the same application do, unless you set `redis.keyPrefix` or `redis.db`.
|
|
293
|
-
- The message is `{ scope: 'user', userId }` or `{ scope: 'all' }`, JSON-encoded.
|
|
365
|
+
- The message is `{ scope: 'user', userId }`, `{ scope: 'tenant', tenantId }` (from `invalidateTenant()`, 11.42.0+) or `{ scope: 'all' }`, JSON-encoded.
|
|
294
366
|
- Subscription uses the shared subscriber connection from `CoreRedisService.getSubscriber()`; the listener is held in a field and detached on destroy, because that connection is shared.
|
|
295
367
|
- **A received broadcast clears locally and does NOT re-publish** (`applyInvalidation()` → `clearUser()` / `clearAll()`, the no-broadcast variants). Re-publishing would bounce the message around the cluster forever.
|
|
296
368
|
- **Publishing is fire-and-forget and failures are debug-level only.** A missed broadcast is not an error: it merely leaves the other replicas' caches stale until their TTL — exactly the no-Redis behaviour. Nothing about a request path depends on the broadcast succeeding.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { looksLikeSystemRole } from '../../common/enums/role.enum';
|
|
2
|
+
import { accessDeniedException } from '../../common/exceptions/access-denied.exception';
|
|
3
|
+
import { RequestContext } from '../../common/services/request-context.service';
|
|
4
|
+
import { checkRoleAccess, resolveGlobalAndTenantRoles, tenantSatisfiableRoles } from './core-tenant.helpers';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Service-level tenant role checks.
|
|
8
|
+
*
|
|
9
|
+
* `@Roles()` protects a controller or resolver method — the route, not the operation. A service is
|
|
10
|
+
* also reached from MCP tools, AI tools, queue processors and other services, and on those paths a
|
|
11
|
+
* tenant role the controller demands is demanded by nobody. Checking in the service closes every
|
|
12
|
+
* entry point at once.
|
|
13
|
+
*
|
|
14
|
+
* The decision mirrors `CoreTenantGuard` on the tenant-header path:
|
|
15
|
+
* - an administrator under `adminBypass` passes,
|
|
16
|
+
* - a GLOBAL role (`RoleEnum.ADMIN`, `multiTenancy.globalOnlyRoles`) is answered by `user.roles`,
|
|
17
|
+
* never by a membership role,
|
|
18
|
+
* - a tenant role is answered by the validated `tenantRole` of the current request (hierarchy roles
|
|
19
|
+
* by level, others by exact match) — without tenant context there is nothing to compare, and it
|
|
20
|
+
* fails,
|
|
21
|
+
* - system roles (`S_*`) are runtime checks of their own and never satisfy anything here.
|
|
22
|
+
*
|
|
23
|
+
* Several roles read as alternatives, as in `@Roles()`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether the current request holds one of `roles` in its tenant. `false` without a signed-in user.
|
|
28
|
+
*/
|
|
29
|
+
export function hasTenantRole(...roles: string[]): boolean {
|
|
30
|
+
const context = RequestContext.get();
|
|
31
|
+
const user = context?.currentUser;
|
|
32
|
+
if (!user) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (context?.isAdminBypass) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const required = roles.filter((role) => typeof role === 'string' && !looksLikeSystemRole(role));
|
|
40
|
+
const { global } = resolveGlobalAndTenantRoles(required);
|
|
41
|
+
if (global.some((role) => user.roles?.includes(role))) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The length guard matters: checkRoleAccess() answers TRUE for an empty required list.
|
|
46
|
+
const tenantRoles = tenantSatisfiableRoles(required);
|
|
47
|
+
return tenantRoles.length > 0 && !!context?.tenantRole && checkRoleAccess(tenantRoles, undefined, context.tenantRole);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Refuses the current operation unless it holds one of `roles` in its tenant.
|
|
52
|
+
*
|
|
53
|
+
* Work the server does on its own — no request context at all, or one no client request created
|
|
54
|
+
* (cron, migration, seed, `runWithBypass…()` outside a request) — passes: there is no caller whose
|
|
55
|
+
* role could be wrong. An ANONYMOUS client request does not pass: it gets 401, and a signed-in caller
|
|
56
|
+
* without the role gets 403.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* async updateAiModels(input: AiModelsInput, serviceOptions?: ServiceOptions) {
|
|
61
|
+
* assertTenantRole(DefaultHR.OWNER, 'Only owners may change the AI models');
|
|
62
|
+
* …
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function assertTenantRole(roles: string | string[], message?: string): void {
|
|
67
|
+
const context = RequestContext.get();
|
|
68
|
+
if (!context?.currentUser && !context?.fromRequest) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const list = Array.isArray(roles) ? roles : [roles];
|
|
72
|
+
if (!hasTenantRole(...list)) {
|
|
73
|
+
throw accessDeniedException(context?.currentUser, message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -61,7 +61,8 @@ interface CachedTenantIds {
|
|
|
61
61
|
* Cache invalidation message broadcast between replicas via Redis pub/sub
|
|
62
62
|
*/
|
|
63
63
|
interface TenantCacheInvalidation {
|
|
64
|
-
scope: 'all' | 'user';
|
|
64
|
+
scope: 'all' | 'tenant' | 'user';
|
|
65
|
+
tenantId?: string;
|
|
65
66
|
userId?: string;
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -148,6 +149,9 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
148
149
|
*/
|
|
149
150
|
private readonly tenantIdsCache = new Map<string, CachedTenantIds>();
|
|
150
151
|
|
|
152
|
+
/** Answers of `multiTenancy.isTenantActive`, keyed by tenant id (same TTL as the other caches). */
|
|
153
|
+
private readonly tenantActiveCache = new Map<string, { active: boolean; expiresAt: number }>();
|
|
154
|
+
|
|
151
155
|
/** Cache TTL in milliseconds. Configurable via multiTenancy.cacheTtlMs (default: 30s, 0 = disabled) */
|
|
152
156
|
private cacheTtlMs: number = 30_000;
|
|
153
157
|
/** Maximum cache entries before eviction */
|
|
@@ -223,7 +227,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
223
227
|
|
|
224
228
|
if (trimmed) {
|
|
225
229
|
const membership = await this.findMembershipCached(user.id, trimmed);
|
|
226
|
-
if (!membership) {
|
|
230
|
+
if (!membership || !(await this.isTenantActive(trimmed))) {
|
|
227
231
|
return {};
|
|
228
232
|
}
|
|
229
233
|
return { tenantId: trimmed, tenantRole: membership.role as string };
|
|
@@ -288,6 +292,17 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
288
292
|
}
|
|
289
293
|
this.membershipCache.clear();
|
|
290
294
|
this.tenantIdsCache.clear();
|
|
295
|
+
this.tenantActiveCache.clear();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Forget the cached `multiTenancy.isTenantActive` answer for a tenant.
|
|
300
|
+
* Call this right after switching a tenant on or off — otherwise the previous answer holds for up to
|
|
301
|
+
* `cacheTtlMs`. Broadcast to every replica when Redis is enabled.
|
|
302
|
+
*/
|
|
303
|
+
invalidateTenant(tenantId: string): void {
|
|
304
|
+
this.tenantActiveCache.delete(tenantId);
|
|
305
|
+
this.publishInvalidation({ scope: 'tenant', tenantId });
|
|
291
306
|
}
|
|
292
307
|
|
|
293
308
|
/**
|
|
@@ -349,6 +364,10 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
349
364
|
request,
|
|
350
365
|
});
|
|
351
366
|
if (apiTokenKind === ApiTokenKind.TENANT) {
|
|
367
|
+
// The token is bound to its tenant (set on the request by the helper); a deactivated one ends it.
|
|
368
|
+
if (request.tenantId) {
|
|
369
|
+
await this.assertTenantActive(request.tenantId);
|
|
370
|
+
}
|
|
352
371
|
return true;
|
|
353
372
|
}
|
|
354
373
|
// A user token restricted to one tenant is bound to it like a header naming it — the helper has
|
|
@@ -397,7 +416,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
397
416
|
// Never block access — S_EVERYONE endpoints are always public.
|
|
398
417
|
if (headerTenantId && request.user?.id) {
|
|
399
418
|
const membership = await this.findMembershipCached(request.user.id, headerTenantId);
|
|
400
|
-
if (membership) {
|
|
419
|
+
if (membership && (await this.isTenantActive(headerTenantId))) {
|
|
401
420
|
request.tenantId = headerTenantId;
|
|
402
421
|
request.tenantRole = capApiTokenTenantRole(request.user, membership.role as string) ?? undefined;
|
|
403
422
|
}
|
|
@@ -520,6 +539,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
520
539
|
if (!membership) {
|
|
521
540
|
throw new ForbiddenException('Not a member of this tenant');
|
|
522
541
|
}
|
|
542
|
+
await this.assertTenantActive(headerTenantId);
|
|
523
543
|
|
|
524
544
|
// A user token with maxTenantRole acts with the lower of its cap and the membership role; `null`
|
|
525
545
|
// (a role the hierarchy cannot compare with the cap) satisfies no tenant role at all.
|
|
@@ -620,7 +640,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
620
640
|
const now = Date.now();
|
|
621
641
|
const cached = this.tenantIdsCache.get(cacheKey);
|
|
622
642
|
if (cached && now < cached.expiresAt) {
|
|
623
|
-
request.tenantIds = cached.ids;
|
|
643
|
+
request.tenantIds = await this.filterActiveTenants(cached.ids);
|
|
624
644
|
return;
|
|
625
645
|
}
|
|
626
646
|
}
|
|
@@ -648,7 +668,8 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
648
668
|
ids = memberships.map((m) => m.tenant as string);
|
|
649
669
|
}
|
|
650
670
|
|
|
651
|
-
request
|
|
671
|
+
// The cache keeps the membership answer; activity is filtered per request through its own cache.
|
|
672
|
+
request.tenantIds = await this.filterActiveTenants(ids);
|
|
652
673
|
|
|
653
674
|
// Store in process-level cache when enabled
|
|
654
675
|
if (ttl > 0) {
|
|
@@ -689,6 +710,8 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
689
710
|
this.clearAll();
|
|
690
711
|
} else if (parsed?.scope === 'user' && parsed.userId) {
|
|
691
712
|
this.clearUser(parsed.userId);
|
|
713
|
+
} else if (parsed?.scope === 'tenant' && parsed.tenantId) {
|
|
714
|
+
this.tenantActiveCache.delete(parsed.tenantId);
|
|
692
715
|
}
|
|
693
716
|
}
|
|
694
717
|
|
|
@@ -698,6 +721,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
698
721
|
protected clearAll(): void {
|
|
699
722
|
this.membershipCache.clear();
|
|
700
723
|
this.tenantIdsCache.clear();
|
|
724
|
+
this.tenantActiveCache.clear();
|
|
701
725
|
}
|
|
702
726
|
|
|
703
727
|
/**
|
|
@@ -814,6 +838,54 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
814
838
|
this.tenantIdsCache.delete(key);
|
|
815
839
|
}
|
|
816
840
|
}
|
|
841
|
+
for (const [key, entry] of this.tenantActiveCache.entries()) {
|
|
842
|
+
if (now >= entry.expiresAt) {
|
|
843
|
+
this.tenantActiveCache.delete(key);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Whether a tenant may be used at all — `multiTenancy.isTenantActive`, cached for `cacheTtlMs`.
|
|
850
|
+
*
|
|
851
|
+
* Always true without the hook. Asked only AFTER the membership check, so a non-member learns
|
|
852
|
+
* nothing about a tenant's state. Protected so a custom guard can answer it differently.
|
|
853
|
+
*/
|
|
854
|
+
protected async isTenantActive(tenantId: string): Promise<boolean> {
|
|
855
|
+
const hook = ConfigService.configFastButReadOnly?.multiTenancy?.isTenantActive;
|
|
856
|
+
if (typeof hook !== 'function') {
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
const ttl = this.cacheTtlMs;
|
|
860
|
+
const now = Date.now();
|
|
861
|
+
if (ttl > 0) {
|
|
862
|
+
const cached = this.tenantActiveCache.get(tenantId);
|
|
863
|
+
if (cached && now < cached.expiresAt) {
|
|
864
|
+
return cached.active;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
const active = (await hook(tenantId, { connection: this.memberModel.db })) !== false;
|
|
868
|
+
if (ttl > 0) {
|
|
869
|
+
this.evictIfOverCapacity(this.tenantActiveCache);
|
|
870
|
+
this.tenantActiveCache.set(tenantId, { active, expiresAt: now + ttl });
|
|
871
|
+
}
|
|
872
|
+
return active;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** 403 for a deactivated tenant. */
|
|
876
|
+
private async assertTenantActive(tenantId: string): Promise<void> {
|
|
877
|
+
if (!(await this.isTenantActive(tenantId))) {
|
|
878
|
+
throw new ForbiddenException('Tenant is inactive');
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** The tenant list without deactivated tenants — unchanged (and free) without the hook. */
|
|
883
|
+
private async filterActiveTenants(ids: string[]): Promise<string[]> {
|
|
884
|
+
if (typeof ConfigService.configFastButReadOnly?.multiTenancy?.isTenantActive !== 'function') {
|
|
885
|
+
return ids;
|
|
886
|
+
}
|
|
887
|
+
const active = await Promise.all(ids.map((id) => this.isTenantActive(id)));
|
|
888
|
+
return ids.filter((_id, index) => active[index]);
|
|
817
889
|
}
|
|
818
890
|
|
|
819
891
|
/**
|
|
@@ -847,6 +919,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
847
919
|
if (!membership) {
|
|
848
920
|
throw new ForbiddenException('Not a member of this tenant');
|
|
849
921
|
}
|
|
922
|
+
await this.assertTenantActive(headerTenantId);
|
|
850
923
|
request.tenantId = headerTenantId;
|
|
851
924
|
request.tenantRole = capApiTokenTenantRole(user, membership.role as string) ?? undefined;
|
|
852
925
|
return true;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
| Use TUS with defaults (signed-in users may upload) | No - works automatically |
|
|
16
16
|
| **Allow anonymous uploads** (public form) | **Yes - Step 0** (the default no longer allows this) |
|
|
17
17
|
| Restrict uploads to specific roles | Yes - Step 0 |
|
|
18
|
-
| Custom upload handling (notifications,
|
|
18
|
+
| Custom upload handling (quota, notifications, ...) | Yes - Step 2 |
|
|
19
19
|
| Hard-code a policy that config must NOT change | Yes - Step 1 |
|
|
20
20
|
| Disable TUS completely | No - just use `TusModule.forRoot({ config: false })` |
|
|
21
21
|
|
|
@@ -134,25 +134,49 @@ export class ServerModule {}
|
|
|
134
134
|
**Create:** `src/server/modules/tus/tus.service.ts`
|
|
135
135
|
|
|
136
136
|
```typescript
|
|
137
|
-
import { Injectable } from '@nestjs/common';
|
|
138
137
|
import { CoreTusService } from '@lenne.tech/nest-server';
|
|
139
138
|
import { Upload } from '@tus/server';
|
|
140
139
|
|
|
141
|
-
@Injectable()
|
|
142
140
|
export class TusService extends CoreTusService {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
// Before the first byte: the declared Upload-Length is known, nothing is stored yet.
|
|
142
|
+
protected override async onUploadCreate(req: any, upload: Upload) {
|
|
143
|
+
const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
|
|
144
|
+
if (!(await quota?.hasRoomFor(req, upload.size))) {
|
|
145
|
+
const error = new Error('Storage quota exceeded');
|
|
146
|
+
(error as any).status_code = 413;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return super.onUploadCreate(req, upload); // records owner + tenant, enforces allowedTypes
|
|
150
|
+
}
|
|
146
151
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
152
|
+
// After the last byte.
|
|
153
|
+
protected override async onUploadComplete(upload: Upload): Promise<void> {
|
|
154
|
+
await super.onUploadComplete(upload); // migrates the file into the configured storage
|
|
155
|
+
// custom logic
|
|
151
156
|
}
|
|
152
157
|
}
|
|
153
158
|
```
|
|
154
159
|
|
|
155
|
-
**
|
|
160
|
+
**Register it** (11.41.9+):
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
// server.module.ts
|
|
164
|
+
TusModule.forRoot({ controller: TusController, service: TusService });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**WHY no constructor dependencies?** The module constructs the service itself, with exactly
|
|
168
|
+
`(connection, options)`. A constructor of your own would receive the wrong arguments. Resolve
|
|
169
|
+
project providers at call time via `this.options?.moduleRef` instead.
|
|
170
|
+
|
|
171
|
+
**WHY always return `super.onUploadCreate()`?** It writes the framework-owned owner and tenant keys
|
|
172
|
+
and checks `allowedTypes`. An override that skips it produces owner-less uploads — reachable by
|
|
173
|
+
every caller allowed to use TUS — and accepts every file type.
|
|
174
|
+
|
|
175
|
+
**Multi-tenancy:** with `multiTenancy` configured, the core validates the tenant header against the
|
|
176
|
+
caller's memberships and stores `metadata.tenantId` on the finished file; a non-member gets 403.
|
|
177
|
+
Without `multiTenancy` nothing changes. On the server nothing to do. On the client, send the tenant header with the TUS upload itself
|
|
178
|
+
(`useLtTusUpload({ headers })`): a header added by the project's API-client interceptor does not reach
|
|
179
|
+
`tus-js-client`, and without it the upload silently stays tenant-less.
|
|
156
180
|
|
|
157
181
|
---
|
|
158
182
|
|
|
@@ -216,6 +240,9 @@ TusModule.forRoot({ config: false });
|
|
|
216
240
|
| Gating `OPTIONS` in a custom controller | Every browser upload fails before the first byte | Leave `handleTusOptions*` on `@Roles(RoleEnum.S_EVERYONE)` — the preflight sends no credentials |
|
|
217
241
|
| Expecting a subclass `@Roles()` to win over config | Roles look ignored | `applyRoles()` overwrites them — Step 1 table |
|
|
218
242
|
| Forgot to register custom controller | Custom handler logic never runs (core controller is used) | Add `controller: TusController` to `forRoot()` |
|
|
243
|
+
| Forgot to register custom service | `onUploadCreate` / `onUploadComplete` overrides never run | Add `service: TusService` to `forRoot()` (11.41.9+) — Step 2 |
|
|
244
|
+
| Constructor dependencies on the service subclass | `undefined` dependencies, or a crash at boot | Resolve them at call time via `this.options?.moduleRef` — Step 2 |
|
|
245
|
+
| Override of `onUploadCreate` without `super` | Uploads have no owner or tenant; `allowedTypes` ignored | `return super.onUploadCreate(req, upload)` — Step 2 |
|
|
219
246
|
| Upload succeeds but the file cannot be downloaded | `403` on `GET /files/id/:id` | `file.downloadRoles` defaults to `[ADMIN]` — widen it, or authorize per file in `checkRights()` |
|
|
220
247
|
| Using wrong endpoint path | 404 on upload | Ensure client uses same path as config |
|
|
221
248
|
|
|
@@ -309,30 +309,103 @@ TusModule.forRoot({
|
|
|
309
309
|
});
|
|
310
310
|
```
|
|
311
311
|
|
|
312
|
+
### Custom Service (11.41.9+)
|
|
313
|
+
|
|
314
|
+
Register a subclass of `CoreTusService` through `TusModule.forRoot({ service })`. Before 11.41.9
|
|
315
|
+
both provider factories constructed `CoreTusService` directly, so a subclass could not be plugged in
|
|
316
|
+
at all.
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
// server.module.ts
|
|
320
|
+
TusModule.forRoot({ controller: TusController, service: TusService });
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The module constructs the service itself, with exactly `(connection, options)` — keep that
|
|
324
|
+
constructor signature, and do not declare constructor dependencies of your own. Reach project
|
|
325
|
+
providers at call time through `this.options?.moduleRef` instead:
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Validate an Upload Before It Starts
|
|
332
|
+
|
|
333
|
+
`onUploadCreate(req, upload)` runs once per upload, after the client declared `Upload-Length` and
|
|
334
|
+
before a single byte is written — the only point at which a quota can refuse an upload without the
|
|
335
|
+
store having grown first. Throw an `Error` carrying `status_code` to refuse:
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { CoreTusService } from '@lenne.tech/nest-server';
|
|
339
|
+
import { Upload } from '@tus/server';
|
|
340
|
+
|
|
341
|
+
export class TusService extends CoreTusService {
|
|
342
|
+
protected override async onUploadCreate(req: any, upload: Upload) {
|
|
343
|
+
const quota = this.options?.moduleRef?.get(QuotaService, { strict: false });
|
|
344
|
+
if (!(await quota?.hasRoomFor(req, upload.size))) {
|
|
345
|
+
const error = new Error('Storage quota exceeded');
|
|
346
|
+
(error as any).status_code = 413;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
return super.onUploadCreate(req, upload);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Always return `super.onUploadCreate()`: it records the owner and the tenant and enforces
|
|
355
|
+
`allowedTypes`. Skipping it leaves the upload owner-less (reachable by every caller who may use TUS)
|
|
356
|
+
and lets any file type through.
|
|
357
|
+
|
|
312
358
|
### Custom Upload Handler
|
|
313
359
|
|
|
314
360
|
Override `onUploadComplete` to customize what happens after upload:
|
|
315
361
|
|
|
316
362
|
```typescript
|
|
317
363
|
// src/server/modules/tus/tus.service.ts
|
|
318
|
-
import { Injectable } from '@nestjs/common';
|
|
319
364
|
import { CoreTusService } from '@lenne.tech/nest-server';
|
|
320
365
|
import { Upload } from '@tus/server';
|
|
321
366
|
|
|
322
|
-
@Injectable()
|
|
323
367
|
export class TusService extends CoreTusService {
|
|
324
368
|
protected override async onUploadComplete(upload: Upload): Promise<void> {
|
|
325
|
-
// Call parent to migrate to
|
|
369
|
+
// Call parent to migrate to the configured file storage
|
|
326
370
|
await super.onUploadComplete(upload);
|
|
327
371
|
|
|
328
372
|
// Custom logic after upload
|
|
329
|
-
const
|
|
330
|
-
await
|
|
331
|
-
await this.analyticsService.trackUpload(upload.id, upload.size);
|
|
373
|
+
const notifications = this.options?.moduleRef?.get(NotificationService, { strict: false });
|
|
374
|
+
await notifications?.sendUploadComplete(upload.metadata?.filename);
|
|
332
375
|
}
|
|
333
376
|
}
|
|
334
377
|
```
|
|
335
378
|
|
|
379
|
+
### Tenant Scoping (11.41.9+)
|
|
380
|
+
|
|
381
|
+
**Only with `multiTenancy` configured.** Without it nothing changes: no header is read, no field is
|
|
382
|
+
written.
|
|
383
|
+
|
|
384
|
+
With multi-tenancy active, an upload carrying the tenant header (`multiTenancy.headerName`, default
|
|
385
|
+
`x-tenant-id`) is validated against the caller's active memberships — the same check GraphQL
|
|
386
|
+
subscriptions use. The TUS routes carry `@SkipTenantCheck()`, so the tenant guard does not do this
|
|
387
|
+
for them.
|
|
388
|
+
|
|
389
|
+
| Request | Result |
|
|
390
|
+
| ---------------------------------------------------- | ------------------------------------------ |
|
|
391
|
+
| No tenant header | upload stays tenant-less, as before |
|
|
392
|
+
| Header naming a tenant the caller belongs to | the finished file gets `metadata.tenantId` |
|
|
393
|
+
| Header naming a tenant the caller does NOT belong to | **403**, nothing is stored |
|
|
394
|
+
|
|
395
|
+
**The header has to come from the TUS client itself.** Projects usually add `X-Tenant-Id` in an
|
|
396
|
+
interceptor of their generated API client, and `tus-js-client` does not go through that client. Without
|
|
397
|
+
an explicit header the upload is accepted, stays tenant-less and bypasses tenant filtering and any
|
|
398
|
+
per-tenant quota, with no error anywhere. Pass it to the upload, e.g. with nuxt-extensions:
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
const { addFiles } = useLtTusUpload({ headers: { 'X-Tenant-Id': currentTenantId } });
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The tenant is recorded under `TUS_TENANT_METADATA_KEY` (`ltTenantId`), next to the owner under
|
|
405
|
+
`TUS_OWNER_METADATA_KEY`. Both keys are framework-owned and always overwritten, so a client cannot
|
|
406
|
+
put either of them into its own `Upload-Metadata`. `metadata.tenantId` on the finished file is what
|
|
407
|
+
the file module's `'tenant'` access preset and any per-file `checkRights()` rule read.
|
|
408
|
+
|
|
336
409
|
---
|
|
337
410
|
|
|
338
411
|
## Integration with FileModule
|
|
@@ -380,14 +453,15 @@ query {
|
|
|
380
453
|
|
|
381
454
|
The following metadata is stored with each GridFS file:
|
|
382
455
|
|
|
383
|
-
| Field | Source
|
|
384
|
-
| ------------------ |
|
|
385
|
-
| `filename` | From TUS `Upload-Metadata` header
|
|
386
|
-
| `contentType` | From TUS `filetype` metadata
|
|
387
|
-
| `tusUploadId` | Original TUS upload ID
|
|
388
|
-
| `originalMetadata` | All TUS metadata
|
|
389
|
-
| `ownerId` | The authenticated uploader (11.35.0+)
|
|
390
|
-
| `
|
|
456
|
+
| Field | Source |
|
|
457
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
458
|
+
| `filename` | From TUS `Upload-Metadata` header |
|
|
459
|
+
| `contentType` | From TUS `filetype` metadata |
|
|
460
|
+
| `tusUploadId` | Original TUS upload ID |
|
|
461
|
+
| `originalMetadata` | All TUS metadata |
|
|
462
|
+
| `ownerId` | The authenticated uploader (11.35.0+) |
|
|
463
|
+
| `tenantId` | The validated tenant, only with `multiTenancy` (11.41.9+) — see [Tenant Scoping](#tenant-scoping-11419) |
|
|
464
|
+
| `uploadedAt` | Completion timestamp |
|
|
391
465
|
|
|
392
466
|
### Upload ownership (11.35.0+)
|
|
393
467
|
|