@lenne.tech/nest-server 11.41.9 → 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.
Files changed (58) hide show
  1. package/.claude/rules/configurable-features.md +3 -2
  2. package/.claude/rules/role-system.md +31 -0
  3. package/.claude/rules/testing.md +4 -4
  4. package/CLAUDE.md +6 -0
  5. package/FRAMEWORK-API.md +3 -2
  6. package/dist/core/common/helpers/graphql-ws-context.helper.js +1 -1
  7. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -1
  8. package/dist/core/common/helpers/outbound-url.helper.d.ts +14 -0
  9. package/dist/core/common/helpers/outbound-url.helper.js +234 -0
  10. package/dist/core/common/helpers/outbound-url.helper.js.map +1 -0
  11. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  12. package/dist/core/common/middleware/request-context.middleware.js +1 -0
  13. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  14. package/dist/core/common/services/request-context.service.d.ts +1 -0
  15. package/dist/core/common/services/request-context.service.js.map +1 -1
  16. package/dist/core/modules/ai/core-ai-mcp.controller.js +5 -2
  17. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  18. package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +1 -1
  19. package/dist/core/modules/ai/services/core-ai-mcp.service.js +3 -2
  20. package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
  21. package/dist/core/modules/ai/tools/ai-tool.registry.js +15 -2
  22. package/dist/core/modules/ai/tools/ai-tool.registry.js.map +1 -1
  23. package/dist/core/modules/tenant/core-tenant-role.helper.d.ts +2 -0
  24. package/dist/core/modules/tenant/core-tenant-role.helper.js +36 -0
  25. package/dist/core/modules/tenant/core-tenant-role.helper.js.map +1 -0
  26. package/dist/core/modules/tenant/core-tenant.guard.d.ts +7 -1
  27. package/dist/core/modules/tenant/core-tenant.guard.js +56 -4
  28. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  29. package/dist/core/modules/user/core-user.service.d.ts +1 -0
  30. package/dist/core/modules/user/core-user.service.js +24 -0
  31. package/dist/core/modules/user/core-user.service.js.map +1 -1
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.js +2 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/tsconfig.build.tsbuildinfo +1 -1
  36. package/docs/REQUEST-LIFECYCLE.md +4 -2
  37. package/migration-guides/11.41.8-to-11.41.9.md +5 -0
  38. package/migration-guides/11.41.x-to-11.42.x.md +161 -0
  39. package/migration-guides/11.9.x-to-11.10.x.md +3 -2
  40. package/package.json +2 -1
  41. package/src/core/common/helpers/graphql-ws-context.helper.ts +1 -1
  42. package/src/core/common/helpers/outbound-url.helper.ts +310 -0
  43. package/src/core/common/interfaces/server-options.interface.ts +54 -0
  44. package/src/core/common/middleware/request-context.middleware.ts +1 -0
  45. package/src/core/common/services/request-context.service.ts +9 -0
  46. package/src/core/modules/ai/README.md +25 -0
  47. package/src/core/modules/ai/core-ai-mcp.controller.ts +22 -3
  48. package/src/core/modules/ai/services/core-ai-mcp.service.ts +9 -3
  49. package/src/core/modules/ai/tools/ai-tool.registry.ts +33 -3
  50. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +18 -11
  51. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +14 -0
  52. package/src/core/modules/tenant/README.md +80 -8
  53. package/src/core/modules/tenant/core-tenant-role.helper.ts +75 -0
  54. package/src/core/modules/tenant/core-tenant.guard.ts +78 -5
  55. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +3 -1
  56. package/src/core/modules/tus/README.md +9 -0
  57. package/src/core/modules/user/core-user.service.ts +53 -0
  58. package/src/index.ts +2 -0
@@ -600,6 +600,31 @@ ai: {
600
600
 
601
601
  - tokens, limits, reset) is available via the `aiUsage` query / `GET /ai/usage`.
602
602
 
603
+ ## Tool access is the user's access
604
+
605
+ An AI client — the chat's agent loop or an MCP client — acts **on behalf of the signed-in user and
606
+ never beyond that user's rights**. Three things make that hold, and a project's tools must not undo
607
+ them:
608
+
609
+ - **Visibility and execution** are decided by `AiToolRegistry.userCanAccess()`, evaluated per request —
610
+ over MCP with the user as of THAT request, not as of the session's `initialize` (11.42.0+), so a
611
+ revoked role stops working without a reconnect.
612
+ Under multi-tenancy (11.42.0+) it resolves a tool's `roles` like `CoreTenantGuard` resolves
613
+ `@Roles()`: a global role (`RoleEnum.ADMIN`, `multiTenancy.globalOnlyRoles`) against `user.roles`, a
614
+ tenant role against the **membership role in the request's tenant** (by hierarchy), and the ADMIN
615
+ bypass only where `multiTenancy.adminBypass` grants it. Before, every role was compared with the
616
+ global `user.roles` — a tool requiring the tenant role `owner` was offered to nobody who held it as
617
+ a membership, and to anyone holding a global role of that name.
618
+ - **Data** is scoped by the request context: a tool runs inside the HTTP request (chat or `/ai/mcp`),
619
+ so `mongooseTenantPlugin` filters by the tenant the guard validated. Without a tenant header there
620
+ is no tenant scope on these routes and tenant-scoped reads are refused (403) — an AI client is
621
+ never broader than its user, at worst narrower.
622
+ - **Service-level rules** apply on every entry point when the service enforces them itself —
623
+ `assertTenantRole()` (see the tenant module README) — instead of relying on a controller's
624
+ `@Roles()`, which a tool calling the service never passes through.
625
+
626
+ Without multi-tenancy, tool roles are compared with `user.roles` exactly as before.
627
+
603
628
  ## MCP server (`ai.mcp: true`)
604
629
 
605
630
  The `AiToolRegistry` also feeds a real **MCP server** at `POST/GET/DELETE /ai/mcp`
@@ -11,6 +11,7 @@ import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
11
11
  import { ErrorCode } from '../error-code/error-codes';
12
12
  import { CoreAiMcpOAuthService } from './services/core-ai-mcp-oauth.service';
13
13
  import { CoreAiMcpService } from './services/core-ai-mcp.service';
14
+ import { AiToolUser } from './tools/ai-tool.registry';
14
15
 
15
16
  /**
16
17
  * MCP Streamable-HTTP endpoint at `/ai/mcp`.
@@ -49,6 +50,18 @@ import { CoreAiMcpService } from './services/core-ai-mcp.service';
49
50
  * ids would learn both that a session id is valid and the internal hostname/PID holding it,
50
51
  * where the same probe against the local map deliberately answers 404.
51
52
  */
53
+ /**
54
+ * One MCP session: its transport, its owner, and the owner AS OF THE LATEST REQUEST. `user` is
55
+ * refreshed on every request so the server — which reads it through a getter — always acts with the
56
+ * user's current rights rather than those from `initialize`.
57
+ */
58
+ interface McpSessionEntry {
59
+ lastUsed: number;
60
+ ownerId: string;
61
+ transport: any;
62
+ user: AiToolUser;
63
+ }
64
+
52
65
  @ApiExcludeController()
53
66
  @Controller('ai/mcp')
54
67
  @Roles(RoleEnum.S_EVERYONE)
@@ -65,7 +78,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
65
78
  * anyone holding another user's session id drives that user's server — the id travels in a
66
79
  * response header, through proxies and client logs.
67
80
  */
68
- private readonly transports = new Map<string, { lastUsed: number; ownerId: string; transport: any }>();
81
+ private readonly transports = new Map<string, McpSessionEntry>();
69
82
 
70
83
  /**
71
84
  * Cap on concurrent MCP sessions held by ONE user.
@@ -160,7 +173,10 @@ export class CoreAiMcpController implements OnModuleDestroy {
160
173
  }
161
174
  const { randomUUID } = await import('node:crypto');
162
175
  const transport: any = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
163
- const server = await this.mcpService.createServer(user);
176
+ // The server reads the user through a getter on every request; `user` below is refreshed by
177
+ // each request of the session, so a role revoked mid-session takes effect at once.
178
+ const created: McpSessionEntry = { lastUsed: Date.now(), ownerId: user.id, transport, user };
179
+ const server = await this.mcpService.createServer(() => created.user);
164
180
  await server.connect(transport);
165
181
  // The MCP SDK transport exposes `onclose` as a callback property (not a DOM
166
182
  // EventTarget), so addEventListener does not apply here.
@@ -171,10 +187,12 @@ export class CoreAiMcpController implements OnModuleDestroy {
171
187
  this.releaseSession(transport.sessionId);
172
188
  }
173
189
  };
174
- entry = { lastUsed: Date.now(), ownerId: user.id, transport };
190
+ entry = created;
175
191
  }
176
192
 
177
193
  entry.lastUsed = Date.now();
194
+ // Same owner (checked above), possibly different rights than at `initialize`.
195
+ entry.user = user;
178
196
  await entry.transport.handleRequest(req, res, req.body);
179
197
 
180
198
  // The sessionId is assigned during handleRequest (initialize); register after.
@@ -280,6 +298,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
280
298
  return;
281
299
  }
282
300
  entry.lastUsed = Date.now();
301
+ entry.user = user;
283
302
  this.registerSession(sessionId as string, user.id);
284
303
  await entry.transport.handleRequest(req, res, (req as any).body);
285
304
  }
@@ -76,17 +76,23 @@ export class CoreAiMcpService {
76
76
  /**
77
77
  * Create a low-level MCP server bound to a user. Uses the JSON-schema tool
78
78
  * definitions directly (no zod conversion needed). The SDK is imported lazily.
79
+ *
80
+ * Pass a GETTER rather than a user object: an MCP session stays open for hours, and every
81
+ * `tools/list` / `tools/call` must use the user's CURRENT rights — a role revoked after
82
+ * `initialize` must not survive in the session. `CoreAiMcpController` passes a getter it refreshes
83
+ * on every request (11.42.0+). A plain object is still accepted and is then fixed for the session.
79
84
  */
80
- async createServer(user: AiToolUser): Promise<any> {
85
+ async createServer(user: AiToolUser | (() => AiToolUser)): Promise<any> {
86
+ const currentUser = (): AiToolUser => (typeof user === 'function' ? user() : user);
81
87
  const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
82
88
  const { CallToolRequestSchema, ListToolsRequestSchema } = await import('@modelcontextprotocol/sdk/types.js');
83
89
 
84
90
  // Loosely typed to decouple from the SDK's strict request/result types.
85
91
  const server: any = new Server({ name: 'lt-nest-server-ai', version: '1.0.0' }, { capabilities: { tools: {} } });
86
92
 
87
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: this.mcpListTools(user) }));
93
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: this.mcpListTools(currentUser()) }));
88
94
  server.setRequestHandler(CallToolRequestSchema, async (request: any) =>
89
- this.mcpCallTool(user, request.params.name, request.params.arguments ?? {}),
95
+ this.mcpCallTool(currentUser(), request.params.name, request.params.arguments ?? {}),
90
96
  );
91
97
 
92
98
  return server;
@@ -1,6 +1,14 @@
1
1
  import { Injectable, Logger } from '@nestjs/common';
2
2
 
3
3
  import { RoleEnum } from '../../../common/enums/role.enum';
4
+ import { ConfigService } from '../../../common/services/config.service';
5
+ import { RequestContext } from '../../../common/services/request-context.service';
6
+ import {
7
+ checkRoleAccess,
8
+ isMultiTenancyActive,
9
+ resolveGlobalAndTenantRoles,
10
+ tenantSatisfiableRoles,
11
+ } from '../../tenant/core-tenant.helpers';
4
12
  import { IAiTool } from '../interfaces/ai-tool.interface';
5
13
 
6
14
  /**
@@ -75,6 +83,13 @@ export class AiToolRegistry {
75
83
 
76
84
  /**
77
85
  * Whether a user satisfies a tool's role requirements.
86
+ *
87
+ * The answer is what an AI client — the MCP server or the chat's agent loop — may do on the user's
88
+ * behalf, so it must not exceed what the user may do through the API. Under multi-tenancy each role
89
+ * is therefore resolved like `CoreTenantGuard` resolves a `@Roles()`: a global role against
90
+ * `user.roles`, a tenant role against the membership role of the current request's tenant (by
91
+ * hierarchy), and the ADMIN bypass only where `multiTenancy.adminBypass` grants it. Without
92
+ * multi-tenancy, roles are compared with `user.roles` as they always were.
78
93
  */
79
94
  userCanAccess(tool: IAiTool, user: AiToolUser | null | undefined): boolean {
80
95
  const roles = tool.roles ?? [];
@@ -94,8 +109,11 @@ export class AiToolRegistry {
94
109
  return false;
95
110
  }
96
111
 
97
- // Admin bypass.
98
- if (user.roles?.includes(RoleEnum.ADMIN)) {
112
+ const multiTenancy = isMultiTenancyActive();
113
+
114
+ // Admin bypass — unless multi-tenancy withholds it (adminBypass: false).
115
+ const adminBypass = !multiTenancy || ConfigService.configFastButReadOnly?.multiTenancy?.adminBypass !== false;
116
+ if (adminBypass && user.roles?.includes(RoleEnum.ADMIN)) {
99
117
  return true;
100
118
  }
101
119
 
@@ -119,6 +137,18 @@ export class AiToolRegistry {
119
137
  RoleEnum.S_VERIFIED,
120
138
  ];
121
139
  const requiredRealRoles = roles.filter((r) => !systemRoles.includes(r));
122
- return requiredRealRoles.some((r) => user.roles?.includes(r));
140
+ if (!multiTenancy) {
141
+ return requiredRealRoles.some((r) => user.roles?.includes(r));
142
+ }
143
+
144
+ // Multi-tenancy: each role answered by the source entitled to answer it.
145
+ const { global } = resolveGlobalAndTenantRoles(requiredRealRoles);
146
+ if (global.some((r) => user.roles?.includes(r))) {
147
+ return true;
148
+ }
149
+ // In a tenant, only the membership role counts; outside one, user.roles — as in the guard.
150
+ // The length guard matters: checkRoleAccess() answers TRUE for an empty required list.
151
+ const tenantRoles = tenantSatisfiableRoles(requiredRealRoles);
152
+ return tenantRoles.length > 0 && checkRoleAccess(tenantRoles, user.roles, RequestContext.get()?.tenantRole);
123
153
  }
124
154
  }
@@ -494,7 +494,12 @@ See: `.claude/rules/module-inheritance.md` for the full pattern.
494
494
 
495
495
  ## Client-Side Configuration
496
496
 
497
- Clients must be configured to use the correct base path and hash passwords:
497
+ Clients must be configured to use the correct base path and hash passwords.
498
+
499
+ > **Nuxt projects on `@lenne.tech/nuxt-extensions`** get all of this preconfigured: `useLtAuthClient()`
500
+ > is the Better-Auth client (base path, password hashing, plugins), and `useLtAuth()` holds the auth
501
+ > state plus the JWT-safe passkey helpers used below. The hand-written client that follows is for
502
+ > clients outside that stack.
498
503
 
499
504
  ```typescript
500
505
  // auth-client.ts (e.g., for Nuxt/Vue)
@@ -622,14 +627,14 @@ async function useBackupCode(code: string) {
622
627
 
623
628
  Handle passkey authentication with session validation fallback.
624
629
 
625
- **IMPORTANT:** For JWT mode (`cookies: false`), you MUST use the `authenticateWithPasskey()` function from the composable instead of `authClient.signIn.passkey()` directly. This is because JWT mode requires sending a `challengeId` to the server for challenge verification.
630
+ **IMPORTANT:** For JWT mode (`cookies: false`), you MUST use the `authenticateWithPasskey()` function from `useLtAuth()` instead of `authClient.signIn.passkey()` directly. This is because JWT mode requires sending a `challengeId` to the server for challenge verification.
626
631
 
627
632
  **Note on Passkey Login Response (v11.13.0+):** The server enriches the passkey verify-authentication response with user data. Better Auth's passkey plugin only returns `{ session }`, but the server fetches the user from the database and adds it to the response. The composable handles both scenarios (user in response vs. fallback to get-session).
628
633
 
629
634
  ```typescript
630
635
  // login.vue - Passkey login (JWT-compatible)
631
- // Use authenticateWithPasskey from useBetterAuth composable
632
- const { authenticateWithPasskey, setUser, validateSession } = useBetterAuth();
636
+ // authenticateWithPasskey from useLtAuth() (@lenne.tech/nuxt-extensions)
637
+ const { authenticateWithPasskey, setUser, validateSession } = useLtAuth();
633
638
 
634
639
  async function onPasskeyLogin() {
635
640
  try {
@@ -677,11 +682,13 @@ In JWT mode (`cookies: false`), the server stores WebAuthn challenges in the dat
677
682
 
678
683
  Register a new passkey for an authenticated user.
679
684
 
680
- **IMPORTANT:** For JWT mode (`cookies: false`), you MUST use the `registerPasskey()` function from the composable. This ensures the `challengeId` is correctly sent to the server.
685
+ **IMPORTANT:** For JWT mode (`cookies: false`), you MUST use the `registerPasskey()` function from `useLtAuth()`. This ensures the `challengeId` is correctly sent to the server.
681
686
 
682
687
  ```typescript
683
688
  // settings.vue - Register new passkey (JWT-compatible)
684
- const { registerPasskey, listPasskeys, deletePasskey } = useBetterAuth();
689
+ // registerPasskey from useLtAuth(); listing and deleting go through the Better-Auth client
690
+ const { registerPasskey } = useLtAuth();
691
+ const authClient = useLtAuthClient();
685
692
 
686
693
  async function onRegisterPasskey() {
687
694
  try {
@@ -707,16 +714,16 @@ async function onRegisterPasskey() {
707
714
 
708
715
  // List user's passkeys (works in both modes)
709
716
  async function loadPasskeys() {
710
- const result = await listPasskeys();
711
- if (result.success) {
712
- passkeys.value = result.passkeys || [];
717
+ const { data, error } = await authClient.passkey.listUserPasskeys();
718
+ if (!error) {
719
+ passkeys.value = data ?? [];
713
720
  }
714
721
  }
715
722
 
716
723
  // Delete a passkey (works in both modes)
717
724
  async function onDeletePasskey(passkeyId: string) {
718
- const result = await deletePasskey(passkeyId);
719
- if (result.success) {
725
+ const { error } = await authClient.passkey.deletePasskey({ id: passkeyId });
726
+ if (!error) {
720
727
  await loadPasskeys();
721
728
  }
722
729
  }
@@ -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 | Filter Applied |
217
- | ------------------------------ | ------------------------------------------------------------------- |
218
- | Not authenticated, no context | Safety Net: `ForbiddenException` on tenantId-schemas |
219
- | Authenticated, no memberships | Safety Net: `ForbiddenException` on tenantId-schemas |
220
- | Authenticated, has memberships | `{ tenantId: { $in: [user's tenant IDs] } }` |
221
- | Authenticated + hierarchy role | `{ tenantId: { $in: [qualified tenant IDs] } }` (filtered by level) |
222
- | Admin without header | No filter (sees all data via `isAdminBypass`) |
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.tenantIds = ids;
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;
@@ -174,7 +174,9 @@ every caller allowed to use TUS — and accepts every file type.
174
174
 
175
175
  **Multi-tenancy:** with `multiTenancy` configured, the core validates the tenant header against the
176
176
  caller's memberships and stores `metadata.tenantId` on the finished file; a non-member gets 403.
177
- Without `multiTenancy` nothing changes. Nothing to do in the project either way.
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.
178
180
 
179
181
  ---
180
182