@lenne.tech/nest-server 11.43.2 → 11.43.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/role-system.md +8 -0
- package/.claude/rules/testing.md +3 -3
- package/FRAMEWORK-API.md +1 -1
- package/dist/core/common/services/core-tenant-context.registry.d.ts +4 -0
- package/dist/core/common/services/core-tenant-context.registry.js.map +1 -1
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +5 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +25 -2
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +26 -4
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -1
- package/migration-guides/11.43.2-to-11.43.3.md +80 -0
- package/package.json +1 -1
- package/src/core/common/services/core-tenant-context.registry.ts +12 -0
- package/src/core/modules/ai/README.md +6 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +59 -3
- package/src/core/modules/tenant/core-tenant.guard.ts +54 -4
|
@@ -179,7 +179,7 @@ JWT-based authentication for existing projects:
|
|
|
179
179
|
| **AI Module** | Prompt orchestrator (auto + plan mode), DB-backed LLM connections, tool registry |
|
|
180
180
|
| **REST Endpoints** | `POST /ai/prompt`, `POST /ai/stream` (SSE); `GET/POST/PUT/DELETE /ai/connections*`, `/ai/connections/available`, `/ai/connections/select`, `/ai/connections/preferences*`, `/ai/conversations*`, `/ai/interactions*`, `/ai/budget-limits*`, `GET /ai/usage` |
|
|
181
181
|
| **GraphQL Endpoints** | `aiPrompt`, `aiAvailableConnections`, `aiSetUserConnection`, connection/preference/conversation/interaction/budget queries + mutations, `aiUsage` |
|
|
182
|
-
| **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); with `ai.mcp.oauth` an OAuth 2.1 router (`/.well-known/*`, `/authorize`, `/token`, `/register`) that `CoreAiMcpOAuthInitializer` mounts during `app.init()` — an Express router behind the Nest middleware and routes, ahead of Nest's not-found handler, so guards and interceptors do not run for it; `/authorize` answers with a built-in consent page (the Better-Auth session from the middleware is the user; Allow is a CSRF-bound POST) |
|
|
182
|
+
| **MCP Server** | Streamable HTTP at `POST/GET/DELETE /ai/mcp` (Bearer auth); a user the controller resolves itself (the OAuth access token, which only this controller accepts, after the guards ran without a user) gets `req.user` and the tenant the header names from an active membership only, through `CoreTenantGuard.resolveMembershipContext()` (11.43.3); with `ai.mcp.oauth` an OAuth 2.1 router (`/.well-known/*`, `/authorize`, `/token`, `/register`) that `CoreAiMcpOAuthInitializer` mounts during `app.init()` — an Express router behind the Nest middleware and routes, ahead of Nest's not-found handler, so guards and interceptors do not run for it; `/authorize` answers with a built-in consent page (the Better-Auth session from the middleware is the user; Allow is a CSRF-bound POST) |
|
|
183
183
|
| **Permission Model** | `S_USER` for prompt/conversations/available/usage; `ADMIN` for connections/preferences/interactions/budgets; encrypted API keys never leave the server (`securityCheck` + `secretFields`) |
|
|
184
184
|
|
|
185
185
|
### Email & Templates
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Migration Guide: 11.43.2 → 11.43.3
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
7
|
+
| **Bugfixes** | An MCP OAuth access token reaches the tenant context: tenant-scoped AI tools answered "Tenant context required" to every OAuth client, members included (§1). The OAuth user is the current user of the request, so audit fields and the role guard plugin apply to tool writes (§2) |
|
|
8
|
+
| **New Features** | None |
|
|
9
|
+
| **Framework API** | `CoreTenantGuard.resolveMembershipContext()` (new, public), the optional `resolveMembership()` on the exported `TenantContextResolver` interface, `CoreAiMcpController.resolveScopedUser()` / `scopeLateResolvedUser()` (new, protected) |
|
|
10
|
+
| **Dependencies** | Unchanged |
|
|
11
|
+
| **Breaking Changes** | None |
|
|
12
|
+
| **Migration Effort** | None. Check §2 if MCP tools of your project write data |
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm update @lenne.tech/nest-server
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Vendor-mode projects pick it up with the core update. No code or configuration change is required.
|
|
21
|
+
|
|
22
|
+
## 1. MCP OAuth clients got no tenant context
|
|
23
|
+
|
|
24
|
+
`CoreTenantGuard` turns the tenant header (`X-Tenant-Id`) into a tenant context only for a user it sees on the
|
|
25
|
+
request. A session cookie and a Better-Auth JWT are resolved by the middleware before the guards. An MCP OAuth
|
|
26
|
+
access token is not: nothing but `CoreAiMcpController` accepts it, so it is resolved after the guards. The request
|
|
27
|
+
therefore never got a tenant, and `mongooseTenantPlugin` refused every tenant-scoped read — a generic MCP client
|
|
28
|
+
could discover, register, pass the consent page, open a session and list its tools, and then every tenant-scoped
|
|
29
|
+
tool answered `Tenant context required`, even with a header naming a tenant the user is an active member of.
|
|
30
|
+
|
|
31
|
+
The controller now asks the guard's own membership check (`CoreTenantGuard.resolveMembershipContext()`, the same
|
|
32
|
+
decision as its `@Roles(S_EVERYONE)` branch) for a user it resolved itself. The result does not depend on the
|
|
33
|
+
credential:
|
|
34
|
+
|
|
35
|
+
| Request | Session cookie / JWT | MCP OAuth token (11.43.2) | MCP OAuth token (11.43.3) |
|
|
36
|
+
| ----------------------------------------------------- | -------------------- | ------------------------- | ------------------------- |
|
|
37
|
+
| Header names a tenant the user is an active member of | that tenant | none — refused | that tenant |
|
|
38
|
+
| Header names a tenant the user is not a member of | none — refused | none — refused | none — refused |
|
|
39
|
+
| No header | none — refused | none — refused | none — refused |
|
|
40
|
+
|
|
41
|
+
The header alone never establishes a tenant, which is what keeps a freely chosen `X-Tenant-Id` harmless.
|
|
42
|
+
|
|
43
|
+
## 2. The OAuth user is the current user of the request
|
|
44
|
+
|
|
45
|
+
For the same reason `req.user` stayed unset for an OAuth request, and `RequestContext.currentUser` is read from
|
|
46
|
+
it. The Mongoose plugins read an empty current user as work the server does on its own:
|
|
47
|
+
|
|
48
|
+
- `createdBy` / `updatedBy` were not written for data an MCP tool created or changed,
|
|
49
|
+
- `mongooseRoleGuardPlugin` let a tool's write change `roles` without checking the caller,
|
|
50
|
+
- `assertTenantRole()` refused the call as anonymous.
|
|
51
|
+
|
|
52
|
+
Since 11.43.3 the controller sets `req.user` for a user it resolved itself, so all three behave as for a cookie or
|
|
53
|
+
JWT request.
|
|
54
|
+
|
|
55
|
+
**What to check:**
|
|
56
|
+
|
|
57
|
+
- **An MCP tool of your project writes `roles` and relied on the role guard plugin letting it through:** under an
|
|
58
|
+
OAuth token that write is now checked against the user, as it always was under a cookie or a JWT. Run such a
|
|
59
|
+
write inside `RequestContext.runWithBypassRoleGuard()` when the tool is meant to assign roles.
|
|
60
|
+
- **A test in your project pins the old refusal** ("Tenant context required" for an OAuth member): flip it to
|
|
61
|
+
assert the data, and keep the cases for a non-member and for a missing header refused.
|
|
62
|
+
- **You override `CoreAiMcpController.handlePost()` or `handleGet()` / `handleDelete()`** without calling
|
|
63
|
+
`super`: resolve the user through `resolveScopedUser(req)` instead of `resolveUser(req)`, or the fix does not
|
|
64
|
+
reach your controller.
|
|
65
|
+
|
|
66
|
+
## Compatibility Notes
|
|
67
|
+
|
|
68
|
+
| Pattern | Status |
|
|
69
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| MCP over a session cookie or a Better-Auth JWT | Unchanged — the guards saw the user before |
|
|
71
|
+
| MCP OAuth without `multiTenancy` | Unchanged apart from §2 |
|
|
72
|
+
| Your own `TenantContextResolver` registered via `setTenantContextResolver()` | Compiles unchanged — `resolveMembership()` is optional; without it an OAuth request gets no tenant, as before |
|
|
73
|
+
| An override of `resolveUser()` | Still used — `resolveScopedUser()` calls it |
|
|
74
|
+
|
|
75
|
+
## Module Documentation
|
|
76
|
+
|
|
77
|
+
- [AI module → Tool access is the user's access](../src/core/modules/ai/README.md#tool-access-is-the-users-access)
|
|
78
|
+
- [Tenant module](../src/core/modules/tenant/README.md)
|
|
79
|
+
- Implementation: `src/core/modules/ai/core-ai-mcp.controller.ts` → `scopeLateResolvedUser()`,
|
|
80
|
+
`src/core/modules/tenant/core-tenant.guard.ts` → `resolveMembershipContext()`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.43.
|
|
3
|
+
"version": "11.43.3",
|
|
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",
|
|
@@ -28,6 +28,18 @@ export interface TenantContextResolver {
|
|
|
28
28
|
* validated against an active membership before it is returned
|
|
29
29
|
*/
|
|
30
30
|
resolve(user: { id: string; roles?: string[] } | undefined, headerTenantId?: string): Promise<ResolvedTenantContext>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The narrower answer `CoreTenantGuard` gives an `@Roles(S_EVERYONE)` route: a tenant only from an
|
|
34
|
+
* ACTIVE membership in the tenant the header names (in an active tenant) — no admin bypass, and no
|
|
35
|
+
* fallback to every membership when no tenant is named. For an HTTP route that authenticates its
|
|
36
|
+
* caller itself, after the guards ran without a user (the MCP endpoint and its OAuth access token),
|
|
37
|
+
* so that route scopes the request exactly as the guard would have.
|
|
38
|
+
*
|
|
39
|
+
* Optional because the interface is public. A caller finding it absent establishes no tenant, which
|
|
40
|
+
* leaves the plugin's safety net to refuse tenant-scoped reads.
|
|
41
|
+
*/
|
|
42
|
+
resolveMembership?(user: { id: string; roles?: string[] }, headerTenantId?: string): Promise<ResolvedTenantContext>;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
let resolver: TenantContextResolver | undefined;
|
|
@@ -619,6 +619,12 @@ them:
|
|
|
619
619
|
so `mongooseTenantPlugin` filters by the tenant the guard validated. Without a tenant header there
|
|
620
620
|
is no tenant scope on these routes and tenant-scoped reads are refused (403) — an AI client is
|
|
621
621
|
never broader than its user, at worst narrower.
|
|
622
|
+
An **MCP OAuth access token** is accepted only by the MCP controller, after the guards ran without a
|
|
623
|
+
user. Since 11.43.3 the controller gives such a request what the guard gives a user it saw: the user
|
|
624
|
+
on the request (so `RequestContext.currentUser`, the audit fields and `mongooseRoleGuardPlugin` see
|
|
625
|
+
it), and the tenant the header names — only when the user is an ACTIVE member of it, through the
|
|
626
|
+
guard's own check (`CoreTenantGuard.resolveMembershipContext()`). Before, every tenant-scoped tool
|
|
627
|
+
answered "Tenant context required" to an OAuth client, even for a member.
|
|
622
628
|
- **Service-level rules** apply on every entry point when the service enforces them itself —
|
|
623
629
|
`assertTenantRole()` (see the tenant module README) — instead of relying on a controller's
|
|
624
630
|
`@Roles()`, which a tool calling the service never passes through.
|
|
@@ -7,6 +7,7 @@ import { Roles } from '../../common/decorators/roles.decorator';
|
|
|
7
7
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
8
8
|
import { ConfigService } from '../../common/services/config.service';
|
|
9
9
|
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
10
|
+
import { getTenantContextResolver } from '../../common/services/core-tenant-context.registry';
|
|
10
11
|
import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
|
|
11
12
|
import { ErrorCode } from '../error-code/error-codes';
|
|
12
13
|
import { getMountedMcpOAuth } from './helpers/ai-mcp-oauth.registry';
|
|
@@ -36,7 +37,9 @@ import { AiToolUser } from './tools/ai-tool.registry';
|
|
|
36
37
|
*
|
|
37
38
|
* When `ai.mcp.oauth` is enabled, the handler additionally accepts OAuth 2.1 access
|
|
38
39
|
* tokens (endpoints mounted by `CoreAiMcpOAuthInitializer`); otherwise it authenticates via
|
|
39
|
-
* Bearer/session token.
|
|
40
|
+
* Bearer/session token. A user resolved here rather than by the guards — the OAuth token always —
|
|
41
|
+
* gets the request scope the guards would have given it (`req.user`, and a tenant from an active
|
|
42
|
+
* membership in the tenant the header names): see {@link CoreAiMcpController.scopeLateResolvedUser}.
|
|
40
43
|
*
|
|
41
44
|
* ## Multi-replica
|
|
42
45
|
*
|
|
@@ -121,7 +124,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
121
124
|
})
|
|
122
125
|
@Post()
|
|
123
126
|
async handlePost(@Req() req: Request, @Res() res: Response): Promise<void> {
|
|
124
|
-
const user = await this.
|
|
127
|
+
const user = await this.resolveScopedUser(req);
|
|
125
128
|
if (!user?.id) {
|
|
126
129
|
this.unauthorized(res);
|
|
127
130
|
return;
|
|
@@ -274,6 +277,59 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
274
277
|
return null;
|
|
275
278
|
}
|
|
276
279
|
|
|
280
|
+
/**
|
|
281
|
+
* {@link resolveUser}, plus — for a user resolved HERE rather than seen by the guards — the request
|
|
282
|
+
* scope the guards give a user they saw (see {@link scopeLateResolvedUser}).
|
|
283
|
+
*
|
|
284
|
+
* Whether the guards saw the user is read BEFORE `resolveUser()` runs, so an override of it that sets
|
|
285
|
+
* `req.user` itself still counts as resolved late.
|
|
286
|
+
*/
|
|
287
|
+
protected async resolveScopedUser(req: Request): Promise<any | null> {
|
|
288
|
+
const seenByGuards = !!(req as any).user?.id;
|
|
289
|
+
const user = await this.resolveUser(req);
|
|
290
|
+
if (user?.id && !seenByGuards) {
|
|
291
|
+
await this.scopeLateResolvedUser(req, user);
|
|
292
|
+
}
|
|
293
|
+
return user;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Give a request whose user this controller resolved itself what the guards give a request whose
|
|
298
|
+
* user they saw: the user on the request, and a tenant from an active membership only.
|
|
299
|
+
*
|
|
300
|
+
* The guards run before this controller, and an MCP OAuth access token is accepted by nothing else
|
|
301
|
+
* — so `CoreTenantGuard` found no `request.user` and never turned the tenant header into a context.
|
|
302
|
+
* In 11.43.2 and earlier that left two things behind:
|
|
303
|
+
* - **No tenant scope.** Every tenant-scoped tool answered "Tenant context required", even with a
|
|
304
|
+
* tenant header naming a tenant the user is an active member of. Fail-closed, but unusable.
|
|
305
|
+
* - **No current user in `RequestContext`**, which reads `req.user` lazily. The Mongoose plugins read
|
|
306
|
+
* that as SYSTEM work: no `createdBy` / `updatedBy`, `mongooseRoleGuardPlugin` let a tool's write
|
|
307
|
+
* change roles, and `assertTenantRole()` refused the call as anonymous.
|
|
308
|
+
*
|
|
309
|
+
* The tenant comes from `CoreTenantGuard.resolveMembershipContext()`, the same decision its
|
|
310
|
+
* `@Roles(S_EVERYONE)` branch makes for a user it saw — so an OAuth token, a session cookie and a
|
|
311
|
+
* Better-Auth JWT reach the same scope. The header alone never establishes one: a tenant the user is
|
|
312
|
+
* not an active member of yields none, and the plugin's safety net refuses the read.
|
|
313
|
+
*/
|
|
314
|
+
protected async scopeLateResolvedUser(req: Request, user: { id: string; roles?: string[] }): Promise<void> {
|
|
315
|
+
(req as any).user = user;
|
|
316
|
+
|
|
317
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
318
|
+
if (!config || config.enabled === false || (req as any).tenantId) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const headerName = (config.headerName ?? 'x-tenant-id').toLowerCase();
|
|
322
|
+
const header = req.headers?.[headerName];
|
|
323
|
+
const resolved = await getTenantContextResolver()?.resolveMembership?.(
|
|
324
|
+
user,
|
|
325
|
+
typeof header === 'string' ? header : undefined,
|
|
326
|
+
);
|
|
327
|
+
if (resolved?.tenantId) {
|
|
328
|
+
(req as any).tenantId = resolved.tenantId;
|
|
329
|
+
(req as any).tenantRole = resolved.tenantRole;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
277
333
|
/**
|
|
278
334
|
* Whether the body is an `initialize` — the one request that legitimately starts a session.
|
|
279
335
|
*
|
|
@@ -298,7 +354,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
|
|
|
298
354
|
* Forward a GET (SSE stream) or DELETE (close) to the session's transport.
|
|
299
355
|
*/
|
|
300
356
|
private async handleSessionRequest(req: Request, res: Response): Promise<void> {
|
|
301
|
-
const user = await this.
|
|
357
|
+
const user = await this.resolveScopedUser(req);
|
|
302
358
|
if (!user?.id) {
|
|
303
359
|
this.unauthorized(res);
|
|
304
360
|
return;
|
|
@@ -181,6 +181,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
181
181
|
// GraphQL WebSocket. See core-tenant-context.registry.ts for why this is a registry and not DI.
|
|
182
182
|
setTenantContextResolver({
|
|
183
183
|
resolve: (user, headerTenantId) => this.resolveTenantContext(user, headerTenantId),
|
|
184
|
+
resolveMembership: (user, headerTenantId) => this.resolveMembershipContext(user, headerTenantId),
|
|
184
185
|
});
|
|
185
186
|
|
|
186
187
|
// Lets the role guards delegate non-system roles to this guard — only while it exists.
|
|
@@ -248,6 +249,39 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
248
249
|
return { tenantIds: carrier.tenantIds ?? [] };
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The tenant context `canActivate()` gives an `@Roles(S_EVERYONE)` route, for a caller it could not
|
|
254
|
+
* see: a route that authenticates its caller ITSELF, after the guards ran without a user.
|
|
255
|
+
*
|
|
256
|
+
* That is the MCP endpoint with an MCP OAuth access token — a credential nothing but its controller
|
|
257
|
+
* accepts, so the guard found no `request.user`, never checked the tenant header against a
|
|
258
|
+
* membership, and every tenant-scoped tool answered "Tenant context required" to a member of that
|
|
259
|
+
* very tenant. Answering through the same {@link memberTenantContext} as the guard's own branch is
|
|
260
|
+
* what keeps the result independent of which credential the caller used.
|
|
261
|
+
*
|
|
262
|
+
* Narrower than {@link resolveTenantContext} on purpose: that one answers for a transport whose every
|
|
263
|
+
* operation needs a scope (no admin bypass here, no list of all memberships without a header).
|
|
264
|
+
*/
|
|
265
|
+
async resolveMembershipContext(
|
|
266
|
+
user: { id: string; roles?: string[] } | undefined,
|
|
267
|
+
headerTenantId?: string,
|
|
268
|
+
): Promise<ResolvedTenantContext> {
|
|
269
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
270
|
+
if (!config || config.enabled === false) {
|
|
271
|
+
return {};
|
|
272
|
+
}
|
|
273
|
+
this.syncConfig(config);
|
|
274
|
+
|
|
275
|
+
const tenantId =
|
|
276
|
+
headerTenantId && typeof headerTenantId === 'string' && headerTenantId.length <= 128
|
|
277
|
+
? headerTenantId.trim()
|
|
278
|
+
: undefined;
|
|
279
|
+
if (!tenantId || !user?.id) {
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
return (await this.memberTenantContext(user, tenantId)) ?? {};
|
|
283
|
+
}
|
|
284
|
+
|
|
251
285
|
/**
|
|
252
286
|
* Subscribe to the cross-replica cache invalidation channel.
|
|
253
287
|
*
|
|
@@ -414,10 +448,10 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
414
448
|
// Optionally enrich with tenant context when header is present and user is an active member.
|
|
415
449
|
// Never block access — S_EVERYONE endpoints are always public.
|
|
416
450
|
if (headerTenantId && request.user?.id) {
|
|
417
|
-
const
|
|
418
|
-
if (
|
|
419
|
-
request.tenantId =
|
|
420
|
-
request.tenantRole =
|
|
451
|
+
const member = await this.memberTenantContext(request.user, headerTenantId);
|
|
452
|
+
if (member) {
|
|
453
|
+
request.tenantId = member.tenantId;
|
|
454
|
+
request.tenantRole = member.tenantRole;
|
|
421
455
|
}
|
|
422
456
|
}
|
|
423
457
|
return true;
|
|
@@ -901,6 +935,22 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
901
935
|
return active;
|
|
902
936
|
}
|
|
903
937
|
|
|
938
|
+
/**
|
|
939
|
+
* The context an ACTIVE membership in an active tenant establishes, or undefined. Never the header
|
|
940
|
+
* alone: a freely chosen tenant id only becomes a scope once it names one of the caller's own tenants.
|
|
941
|
+
* Shared by the `S_EVERYONE` branch of `canActivate()` and {@link resolveMembershipContext}.
|
|
942
|
+
*/
|
|
943
|
+
private async memberTenantContext(
|
|
944
|
+
user: { id: string; roles?: string[] },
|
|
945
|
+
tenantId: string,
|
|
946
|
+
): Promise<{ tenantId: string; tenantRole?: string } | undefined> {
|
|
947
|
+
const membership = await this.findMembershipCached(user.id, tenantId);
|
|
948
|
+
if (!membership || !(await this.isTenantActive(tenantId))) {
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
return { tenantId, tenantRole: capApiTokenTenantRole(user, membership.role as string) ?? undefined };
|
|
952
|
+
}
|
|
953
|
+
|
|
904
954
|
/** 403 for a deactivated tenant. */
|
|
905
955
|
private async assertTenantActive(tenantId: string): Promise<void> {
|
|
906
956
|
if (!(await this.isTenantActive(tenantId))) {
|