@lenne.tech/nest-server 11.41.9 → 11.42.1
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 +4 -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 +6 -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 +1 -0
- package/dist/core/modules/tus/core-tus.service.js +29 -0
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.d.ts +2 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +1 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.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 +4 -2
- package/migration-guides/11.41.8-to-11.41.9.md +5 -0
- package/migration-guides/11.41.x-to-11.42.x.md +161 -0
- package/migration-guides/11.42.0-to-11.42.1.md +66 -0
- package/migration-guides/11.9.x-to-11.10.x.md +3 -2
- package/package.json +8 -7
- 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 +71 -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 +3 -1
- package/src/core/modules/tus/README.md +20 -0
- package/src/core/modules/tus/core-tus.service.ts +45 -0
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +4 -1
- package/src/core/modules/user/core-user.service.ts +53 -0
- package/src/index.ts +2 -0
|
@@ -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;
|
|
@@ -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.
|
|
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
|
|
|
@@ -107,6 +107,17 @@ The TUS server automatically handles CORS headers for browser-based clients:
|
|
|
107
107
|
- `Upload-Offset`
|
|
108
108
|
- `Upload-Metadata`
|
|
109
109
|
|
|
110
|
+
**`Access-Control-Allow-Origin` follows the API's CORS configuration (11.42.1+).** The tus handler
|
|
111
|
+
writes its responses itself, and without `allowedOrigins` `@tus/server` answers `*`. With cookie
|
|
112
|
+
authentication and separate app and API origins the request is credentialed, and every browser
|
|
113
|
+
refuses `*` there — uploads failed in the browser although the server answered 201. The origins now
|
|
114
|
+
come from `buildCorsConfig()` (`appUrl`, `baseUrl`, `cors.allowedOrigins`, `cors.allowAll`), the same
|
|
115
|
+
decision REST, GraphQL and Better-Auth make; `tus.allowedOrigins` overrides. Without credentialed CORS
|
|
116
|
+
(cookies off, or no origin resolvable) the tus default `*` stays.
|
|
117
|
+
|
|
118
|
+
The preflight (`OPTIONS`) is answered by Nest's CORS layer and was never affected — test an
|
|
119
|
+
**authenticated** request with an `Origin` header when checking this.
|
|
120
|
+
|
|
110
121
|
---
|
|
111
122
|
|
|
112
123
|
## Configuration
|
|
@@ -392,6 +403,15 @@ for them.
|
|
|
392
403
|
| Header naming a tenant the caller belongs to | the finished file gets `metadata.tenantId` |
|
|
393
404
|
| Header naming a tenant the caller does NOT belong to | **403**, nothing is stored |
|
|
394
405
|
|
|
406
|
+
**The header has to come from the TUS client itself.** Projects usually add `X-Tenant-Id` in an
|
|
407
|
+
interceptor of their generated API client, and `tus-js-client` does not go through that client. Without
|
|
408
|
+
an explicit header the upload is accepted, stays tenant-less and bypasses tenant filtering and any
|
|
409
|
+
per-tenant quota, with no error anywhere. Pass it to the upload, e.g. with nuxt-extensions:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
const { addFiles } = useLtTusUpload({ headers: { 'X-Tenant-Id': currentTenantId } });
|
|
413
|
+
```
|
|
414
|
+
|
|
395
415
|
The tenant is recorded under `TUS_TENANT_METADATA_KEY` (`ltTenantId`), next to the owner under
|
|
396
416
|
`TUS_OWNER_METADATA_KEY`. Both keys are framework-owned and always overwritten, so a client cannot
|
|
397
417
|
put either of them into its own `Upload-Metadata`. `metadata.tenantId` on the finished file is what
|
|
@@ -9,6 +9,7 @@ import { Readable } from 'stream';
|
|
|
9
9
|
|
|
10
10
|
import { GridFSHelper } from '../../common/helpers/gridfs.helper';
|
|
11
11
|
import { ITusConfig } from '../../common/interfaces/server-options.interface';
|
|
12
|
+
import { buildCorsConfig } from '../../common/helpers/cookies.helper';
|
|
12
13
|
import { ConfigService } from '../../common/services/config.service';
|
|
13
14
|
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
14
15
|
import { CoreS3Service } from '../../common/services/core-s3.service';
|
|
@@ -638,6 +639,46 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
638
639
|
return { metadata };
|
|
639
640
|
}
|
|
640
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The origins the tus handler may answer a cross-origin request with — or `undefined` to keep the
|
|
644
|
+
* tus server's own default (`*`).
|
|
645
|
+
*
|
|
646
|
+
* The tus handler writes its responses itself, past the API's CORS layer, and without
|
|
647
|
+
* `allowedOrigins` @tus/server sets `Access-Control-Allow-Origin: *`. On a credentialed request —
|
|
648
|
+
* cookie authentication with separate app and API origins — every browser refuses that, so no
|
|
649
|
+
* browser upload could succeed although the server answered 201. Preflights and 401s were fine
|
|
650
|
+
* (Nest answers those), which is why nothing noticed: only an AUTHENTICATED request reaches it.
|
|
651
|
+
*
|
|
652
|
+
* Taken from `tus.allowedOrigins` when set, else from `buildCorsConfig()` — the same decision REST,
|
|
653
|
+
* GraphQL and Better-Auth make. Without credentialed CORS configured the previous default stays.
|
|
654
|
+
*/
|
|
655
|
+
protected resolveCorsOrigins(): ((origin: string) => boolean) | undefined {
|
|
656
|
+
const toOrigin = (value: string): string => {
|
|
657
|
+
try {
|
|
658
|
+
return new URL(value).origin;
|
|
659
|
+
} catch {
|
|
660
|
+
return value;
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
const fromList = (list: string[]) => {
|
|
664
|
+
const allowed = new Set(list.filter((entry) => typeof entry === 'string').map(toOrigin));
|
|
665
|
+
return (origin: string) => allowed.has(toOrigin(origin));
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
if (Array.isArray(this.config.allowedOrigins) && this.config.allowedOrigins.length) {
|
|
669
|
+
return fromList(this.config.allowedOrigins);
|
|
670
|
+
}
|
|
671
|
+
const options = this.options?.configService?.configFastButReadOnly ?? ConfigService.configFastButReadOnly ?? {};
|
|
672
|
+
const cors = buildCorsConfig(options);
|
|
673
|
+
if (cors.credentials !== true) {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
if (cors.origin === true) {
|
|
677
|
+
return () => true;
|
|
678
|
+
}
|
|
679
|
+
return Array.isArray(cors.origin) ? fromList(cors.origin as string[]) : undefined;
|
|
680
|
+
}
|
|
681
|
+
|
|
641
682
|
/**
|
|
642
683
|
* The VALIDATED tenant a new upload belongs to, or `undefined`.
|
|
643
684
|
*
|
|
@@ -716,8 +757,12 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
716
757
|
// correct for the single-replica deployment that configuration describes.
|
|
717
758
|
const locker = this.createLocker();
|
|
718
759
|
|
|
760
|
+
const cors = this.resolveCorsOrigins();
|
|
719
761
|
const server = new Server({
|
|
720
762
|
allowedHeaders: this.config.allowedHeaders || DEFAULT_TUS_ALLOWED_HEADERS,
|
|
763
|
+
// Without these, @tus/server answers every request with `Access-Control-Allow-Origin: *`,
|
|
764
|
+
// which a browser refuses on a credentialed (cookie) request — see resolveCorsOrigins().
|
|
765
|
+
...(cors ? { allowedCredentials: true, allowedOrigins: cors } : {}),
|
|
721
766
|
datastore,
|
|
722
767
|
...(locker ? { locker } : {}),
|
|
723
768
|
maxSize: this.config.maxSize,
|
|
@@ -31,13 +31,16 @@ export const DEFAULT_TUS_ALLOWED_HEADERS: string[] = [];
|
|
|
31
31
|
* Default TUS configuration
|
|
32
32
|
*/
|
|
33
33
|
export const DEFAULT_TUS_CONFIG: Required<
|
|
34
|
-
Omit<ITusConfig, 'allowedTypes' | 'creation' | 'expiration'> & {
|
|
34
|
+
Omit<ITusConfig, 'allowedOrigins' | 'allowedTypes' | 'creation' | 'expiration'> & {
|
|
35
|
+
allowedOrigins: undefined;
|
|
35
36
|
allowedTypes: undefined;
|
|
36
37
|
creation: boolean;
|
|
37
38
|
expiration: ITusExpirationConfig;
|
|
38
39
|
}
|
|
39
40
|
> = {
|
|
40
41
|
allowedHeaders: DEFAULT_TUS_ALLOWED_HEADERS,
|
|
42
|
+
// Derived from the API CORS configuration (CoreTusService.resolveCorsOrigins())
|
|
43
|
+
allowedOrigins: undefined,
|
|
41
44
|
allowedTypes: undefined,
|
|
42
45
|
checksum: true,
|
|
43
46
|
concatenation: true,
|
|
@@ -5,6 +5,7 @@ import { sha256 } from 'js-sha256';
|
|
|
5
5
|
import { Document, Model } from 'mongoose';
|
|
6
6
|
|
|
7
7
|
import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
|
|
8
|
+
import { accessDeniedException } from '../../common/exceptions/access-denied.exception';
|
|
8
9
|
import { resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
|
|
9
10
|
import { maskEmail } from '../../common/helpers/logging.helper';
|
|
10
11
|
import { assignPlain, isQueryableString, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
@@ -673,6 +674,8 @@ export abstract class CoreUserService<
|
|
|
673
674
|
const oldUser = (await this.mainDbModel.findById(id).lean().exec()) as null | TUser;
|
|
674
675
|
const oldEmail = oldUser?.email;
|
|
675
676
|
|
|
677
|
+
this.assertCredentialChangeAllowed(oldUser, input, serviceOptions);
|
|
678
|
+
|
|
676
679
|
// Capture the submitted password for the IAM sync before super.update()
|
|
677
680
|
// hashes it in place.
|
|
678
681
|
//
|
|
@@ -738,6 +741,56 @@ export abstract class CoreUserService<
|
|
|
738
741
|
return updatedUser;
|
|
739
742
|
}
|
|
740
743
|
|
|
744
|
+
/**
|
|
745
|
+
* Refuses a change of another account's e-mail address or password where the configuration promises
|
|
746
|
+
* tenant isolation from platform administrators.
|
|
747
|
+
*
|
|
748
|
+
* With multi-tenancy active and `adminBypass: false`, a platform administrator deliberately has no
|
|
749
|
+
* way into a tenant's data. Setting a tenant user's address or password is a way around that: change
|
|
750
|
+
* the address, request a reset, sign in as that person. The same holds for whoever created the
|
|
751
|
+
* account, because `S_CREATOR` may update it and the e-mail field is open to everyone who may.
|
|
752
|
+
*
|
|
753
|
+
* Untouched by design:
|
|
754
|
+
* - the account changing its own credentials,
|
|
755
|
+
* - profile fields, and an e-mail address sent along unchanged,
|
|
756
|
+
* - work without a signed-in user (invitation, password reset), which is the system's own,
|
|
757
|
+
* - every configuration other than multi-tenancy with `adminBypass: false`.
|
|
758
|
+
*
|
|
759
|
+
* `force` does not lift it: the rule is about WHO acts, and system work is defined by the absence of
|
|
760
|
+
* a current user. Protected so a project can widen or narrow it.
|
|
761
|
+
*/
|
|
762
|
+
protected assertCredentialChangeAllowed(
|
|
763
|
+
target: null | TUser,
|
|
764
|
+
input: TUserInput,
|
|
765
|
+
serviceOptions?: ServiceOptions,
|
|
766
|
+
): void {
|
|
767
|
+
const multiTenancy = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
768
|
+
if (!multiTenancy || multiTenancy.enabled === false || multiTenancy.adminBypass !== false) {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const currentUser = serviceOptions?.currentUser;
|
|
772
|
+
if (!currentUser?.id || !target) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// No exemption for administrator TARGETS: an administrator may write `roles`, so "promote the
|
|
776
|
+
// tenant account to ADMIN, then change its address" would open every account in two requests.
|
|
777
|
+
const targetId = String((target as any)._id ?? (target as any).id);
|
|
778
|
+
if (String(currentUser.id) === targetId) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const submitted = input as { email?: unknown; password?: unknown };
|
|
783
|
+
const normalize = (value: unknown) => (typeof value === 'string' ? value.trim().toLowerCase() : value);
|
|
784
|
+
const changesEmail = submitted?.email !== undefined && normalize(submitted.email) !== normalize(target.email);
|
|
785
|
+
const changesPassword = submitted?.password !== undefined;
|
|
786
|
+
if (changesEmail || changesPassword) {
|
|
787
|
+
throw accessDeniedException(
|
|
788
|
+
currentUser,
|
|
789
|
+
'The e-mail address and password of a tenant account can only be changed by the account itself',
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
741
794
|
/**
|
|
742
795
|
* Delete user with automatic cleanup of IAM auth data
|
|
743
796
|
*
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ export * from './core/common/helpers/input.helper';
|
|
|
47
47
|
export * from './core/common/helpers/logging.helper';
|
|
48
48
|
export * from './core/common/helpers/meta.helper';
|
|
49
49
|
export * from './core/common/helpers/model.helper';
|
|
50
|
+
export * from './core/common/helpers/outbound-url.helper';
|
|
50
51
|
export * from './core/common/helpers/process-diagnostics.helper';
|
|
51
52
|
export * from './core/common/helpers/register-enum.helper';
|
|
52
53
|
export * from './core/common/helpers/scim.helper';
|
|
@@ -258,6 +259,7 @@ export * from './core/modules/tenant/core-tenant.decorators';
|
|
|
258
259
|
export * from './core/modules/tenant/core-tenant.enums';
|
|
259
260
|
export * from './core/modules/tenant/core-tenant.guard';
|
|
260
261
|
export * from './core/modules/tenant/core-role-scope.registry';
|
|
262
|
+
export * from './core/modules/tenant/core-tenant-role.helper';
|
|
261
263
|
export * from './core/modules/tenant/core-tenant.helpers';
|
|
262
264
|
export * from './core/modules/tenant/core-tenant.module';
|
|
263
265
|
export * from './core/modules/tenant/core-tenant.service';
|