@lenne.tech/nest-server 11.34.1 → 11.35.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/architecture.md +3 -1
- package/.claude/rules/configurable-features.md +3 -3
- package/.claude/rules/role-system.md +281 -0
- package/CLAUDE.md +11 -1
- package/FRAMEWORK-API.md +7 -3
- package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
- package/dist/core/common/decorators/nested-type.registry.js +17 -0
- package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
- package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
- package/dist/core/common/decorators/restricted.decorator.js +52 -16
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
- package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
- package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
- package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
- package/dist/core/common/decorators/unified-field.decorator.js +6 -3
- package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
- package/dist/core/common/enums/role.enum.d.ts +8 -0
- package/dist/core/common/enums/role.enum.js +28 -1
- package/dist/core/common/enums/role.enum.js.map +1 -1
- package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
- package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
- package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.js +3 -2
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
- package/dist/core/common/helpers/gridfs.helper.js +1 -1
- package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
- package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
- package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
- package/dist/core/common/services/core-s3.service.js +4 -1
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
- package/dist/core/common/services/core-tenant-context.registry.js +12 -0
- package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
- package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/roles.guard.js +2 -2
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
- package/dist/core/modules/file/core-file-access-audit.initializer.d.ts +13 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
- package/dist/core/modules/file/core-file.service.d.ts +3 -1
- package/dist/core/modules/file/core-file.service.js +62 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-access.helper.d.ts +18 -0
- package/dist/core/modules/file/file-access.helper.js +46 -0
- package/dist/core/modules/file/file-access.helper.js.map +1 -0
- package/dist/core/modules/file/file-metadata.helper.js +1 -1
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
- package/dist/core/modules/file/file-roles.config.d.ts +31 -0
- package/dist/core/modules/file/file-roles.config.js +127 -0
- package/dist/core/modules/file/file-roles.config.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
- package/dist/core/modules/file/file-roles.helper.js +16 -34
- package/dist/core/modules/file/file-roles.helper.js.map +1 -1
- package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
- package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
- package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
- package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
- package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +5 -0
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.service.js +25 -0
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.service.js +34 -4
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/index.d.ts +1 -0
- package/dist/core/modules/tus/index.js +1 -0
- package/dist/core/modules/tus/index.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/user/core-user.service.js +5 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +8 -0
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +13 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file.service.js +4 -1
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +26 -6
- package/dist/test/test.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +60 -1
- package/migration-guides/11.34.x-to-11.35.x.md +846 -0
- package/migration-guides/11.35.0-to-11.35.1.md +152 -0
- package/package.json +1 -1
- package/src/core/common/decorators/nested-type.registry.ts +42 -0
- package/src/core/common/decorators/restricted.decorator.ts +149 -21
- package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
- package/src/core/common/decorators/unified-field.decorator.ts +4 -3
- package/src/core/common/enums/role.enum.ts +116 -0
- package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
- package/src/core/common/helpers/file.helper.ts +11 -5
- package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
- package/src/core/common/helpers/gridfs.helper.ts +18 -2
- package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
- package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
- package/src/core/common/interfaces/server-options.interface.ts +139 -7
- package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
- package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
- package/src/core/common/services/core-s3.service.ts +9 -1
- package/src/core/common/services/core-tenant-context.registry.ts +50 -0
- package/src/core/modules/auth/guards/roles.guard.ts +7 -5
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
- package/src/core/modules/file/README.md +71 -0
- package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -0
- package/src/core/modules/file/core-file.service.ts +166 -9
- package/src/core/modules/file/file-access.helper.ts +149 -0
- package/src/core/modules/file/file-metadata.helper.ts +13 -5
- package/src/core/modules/file/file-roles.config.ts +357 -0
- package/src/core/modules/file/file-roles.helper.ts +24 -60
- package/src/core/modules/tenant/README.md +20 -1
- package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
- package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
- package/src/core/modules/tenant/core-tenant.module.ts +12 -0
- package/src/core/modules/tenant/core-tenant.service.ts +56 -0
- package/src/core/modules/tus/README.md +46 -7
- package/src/core/modules/tus/core-tus.service.ts +94 -5
- package/src/core/modules/tus/index.ts +1 -0
- package/src/core/modules/tus/tus.constants.ts +17 -1
- package/src/core/modules/user/core-user.service.ts +12 -0
- package/src/core/modules/user/inputs/core-user.input.ts +28 -2
- package/src/core.module.ts +72 -3
- package/src/index.ts +7 -0
- package/src/server/modules/file/file.service.ts +45 -2
- package/src/test/test.helper.ts +54 -11
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { GLOBAL_ONLY_ROLES, isSystemRole, looksLikeSystemRole } from '../../common/enums/role.enum';
|
|
2
|
+
import { ConfigService } from '../../common/services/config.service';
|
|
3
|
+
import { DEFAULT_ROLE_HIERARCHY } from './core-tenant.enums';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a role's authority comes from — and therefore which source may answer it.
|
|
7
|
+
*
|
|
8
|
+
* This is the tenant boundary expressed as data. Roles are plain strings at runtime
|
|
9
|
+
* (`RoleEnum.ADMIN` IS `'admin'`), and they arrive from three trust levels: the framework, the
|
|
10
|
+
* project, and the CUSTOMER (membership roles are free text). Comparing them with `===` in one
|
|
11
|
+
* shared namespace is what allowed a tenant owner to grant themselves platform authority by naming
|
|
12
|
+
* their tenant role `'admin'`.
|
|
13
|
+
*
|
|
14
|
+
* Encoding the scope as an ATTRIBUTE rather than in the name is deliberate, and it is what makes
|
|
15
|
+
* this forward-compatible with admin-managed roles stored in the database: a future
|
|
16
|
+
* `DbRoleScopeSource` can feed the same registry, and every caller keeps working unchanged. A
|
|
17
|
+
* naming convention (e.g. storing tenant roles as `t:owner`) would instead put the scope inside a
|
|
18
|
+
* string that whoever creates the role can mistype, and would add a normalization step to every
|
|
19
|
+
* comparison — inside the authorization path, which is the last place that should grow moving parts.
|
|
20
|
+
*/
|
|
21
|
+
export enum RoleScope {
|
|
22
|
+
/** Authority granted by the PLATFORM. Resolved against `user.roles`, never a membership role. */
|
|
23
|
+
GLOBAL = 'global',
|
|
24
|
+
|
|
25
|
+
/** A runtime-context check (`s_*`), never a stored role and never a string comparison. */
|
|
26
|
+
SYSTEM = 'system',
|
|
27
|
+
|
|
28
|
+
/** Authority granted WITHIN one tenant. Resolved against `membership.role` in tenant context. */
|
|
29
|
+
TENANT = 'tenant',
|
|
30
|
+
|
|
31
|
+
/** Declared nowhere. Grants nothing — see {@link CoreRoleScopeRegistry.scopeOf}. */
|
|
32
|
+
UNKNOWN = 'unknown',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A source of role→scope knowledge.
|
|
37
|
+
*
|
|
38
|
+
* Implemented today by the config reader below. The interface exists so an admin-managed,
|
|
39
|
+
* database-backed source can be added later without touching the guards: register it, and every
|
|
40
|
+
* `scopeOf()` caller picks it up.
|
|
41
|
+
*/
|
|
42
|
+
export interface RoleScopeSource {
|
|
43
|
+
/** All roles this source declares as globally scoped. */
|
|
44
|
+
globalRoles(): Iterable<string>;
|
|
45
|
+
|
|
46
|
+
/** All roles this source declares as tenant scoped. */
|
|
47
|
+
tenantRoles(): Iterable<string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the scope of a role, from one or more sources.
|
|
52
|
+
*
|
|
53
|
+
* **Deny by default.** A role no source declares resolves to {@link RoleScope.UNKNOWN}, and callers
|
|
54
|
+
* must treat that as "grants nothing". That is the property worth having: an undeclared membership
|
|
55
|
+
* role cannot accidentally line up with an undeclared required role and authorize something. The
|
|
56
|
+
* previous behaviour — exact string match against whatever happened to be stored — is precisely the
|
|
57
|
+
* hole this replaces.
|
|
58
|
+
*
|
|
59
|
+
* The registry is a plain object rather than a Nest provider on purpose: `checkRestricted()` runs
|
|
60
|
+
* far outside DI (it is called from decorators and helpers), and giving it an injected dependency
|
|
61
|
+
* would put a container lookup in the field-level authorization path.
|
|
62
|
+
*/
|
|
63
|
+
export class CoreRoleScopeRegistry {
|
|
64
|
+
private readonly sources: RoleScopeSource[] = [];
|
|
65
|
+
|
|
66
|
+
/** Drop all registered sources. Used by tests and by config reloads. */
|
|
67
|
+
clear(): void {
|
|
68
|
+
this.sources.length = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Roles that are declared in BOTH scopes — a contradiction that must fail the boot.
|
|
73
|
+
*
|
|
74
|
+
* Such a role would have to be resolved against `user.roles` and `membership.role` at the same
|
|
75
|
+
* time. Whichever way it were decided, one of the two declarations would silently not hold.
|
|
76
|
+
*/
|
|
77
|
+
conflicts(): string[] {
|
|
78
|
+
const global = this.collect((source) => source.globalRoles());
|
|
79
|
+
const tenant = this.collect((source) => source.tenantRoles());
|
|
80
|
+
return [...global].filter((role) => tenant.has(role)).sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Is this role declared as tenant-scoped by any source? */
|
|
84
|
+
isTenantRole(role: string): boolean {
|
|
85
|
+
return this.scopeOf(role) === RoleScope.TENANT;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Register a source. Later sources add to, never replace, earlier ones. */
|
|
89
|
+
register(source: RoleScopeSource): void {
|
|
90
|
+
this.sources.push(source);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Scope of a single role.
|
|
95
|
+
*
|
|
96
|
+
* Order matters: system roles first (they are never stored and never string-compared), then
|
|
97
|
+
* global (so a project cannot demote a framework role by declaring it per tenant), then tenant.
|
|
98
|
+
*/
|
|
99
|
+
scopeOf(role: string): RoleScope {
|
|
100
|
+
if (typeof role !== 'string') {
|
|
101
|
+
return RoleScope.UNKNOWN;
|
|
102
|
+
}
|
|
103
|
+
if (isSystemRole(role) || looksLikeSystemRole(role)) {
|
|
104
|
+
return RoleScope.SYSTEM;
|
|
105
|
+
}
|
|
106
|
+
if (GLOBAL_ONLY_ROLES.includes(role) || this.collect((source) => source.globalRoles()).has(role)) {
|
|
107
|
+
return RoleScope.GLOBAL;
|
|
108
|
+
}
|
|
109
|
+
if (this.collect((source) => source.tenantRoles()).has(role)) {
|
|
110
|
+
return RoleScope.TENANT;
|
|
111
|
+
}
|
|
112
|
+
return RoleScope.UNKNOWN;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Is any source able to answer tenant-scope questions at all?
|
|
117
|
+
*
|
|
118
|
+
* Lets callers distinguish "declared as NOT a tenant role" from "nothing is declared yet", which
|
|
119
|
+
* are very different answers when deciding whether to deny.
|
|
120
|
+
*/
|
|
121
|
+
hasTenantKnowledge(): boolean {
|
|
122
|
+
return this.collect((source) => source.tenantRoles()).size > 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Split required roles by the source entitled to answer them.
|
|
127
|
+
*
|
|
128
|
+
* `unknown` is returned separately rather than folded into either half, so callers can decide
|
|
129
|
+
* explicitly. The guards treat it as "grants nothing via tenant membership" but still resolve it
|
|
130
|
+
* against `user.roles` — that keeps a project which never configured `multiTenancy` working
|
|
131
|
+
* exactly as before, while ensuring a membership role can never be the thing that satisfies it.
|
|
132
|
+
*/
|
|
133
|
+
split(requiredRoles: string[]): { global: string[]; system: string[]; tenant: string[]; unknown: string[] } {
|
|
134
|
+
const result = { global: [] as string[], system: [] as string[], tenant: [] as string[], unknown: [] as string[] };
|
|
135
|
+
for (const role of requiredRoles ?? []) {
|
|
136
|
+
switch (this.scopeOf(role)) {
|
|
137
|
+
case RoleScope.GLOBAL:
|
|
138
|
+
result.global.push(role);
|
|
139
|
+
break;
|
|
140
|
+
case RoleScope.SYSTEM:
|
|
141
|
+
result.system.push(role);
|
|
142
|
+
break;
|
|
143
|
+
case RoleScope.TENANT:
|
|
144
|
+
result.tenant.push(role);
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
result.unknown.push(role);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The `multiTenancy` config, read fresh on every lookup and ALWAYS consulted.
|
|
155
|
+
*
|
|
156
|
+
* Built in rather than registered, because a registered source only exists after
|
|
157
|
+
* `CoreTenantModule.forRoot()` has run — and the guards can be constructed before that (test
|
|
158
|
+
* fixtures, standalone usage). A registry that silently knows nothing is worse than no registry:
|
|
159
|
+
* `globalOnlyRoles` would be ignored and the roles it protects would fall back to being
|
|
160
|
+
* tenant-satisfiable, which is precisely the escalation this exists to prevent.
|
|
161
|
+
*/
|
|
162
|
+
private configSource(): RoleScopeSource {
|
|
163
|
+
return {
|
|
164
|
+
globalRoles: () => ConfigService.configFastButReadOnly?.multiTenancy?.globalOnlyRoles ?? [],
|
|
165
|
+
tenantRoles: () => {
|
|
166
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
167
|
+
return [
|
|
168
|
+
...Object.keys(config?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY),
|
|
169
|
+
...(config?.additionalMembershipRoles ?? []),
|
|
170
|
+
];
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private collect(pick: (source: RoleScopeSource) => Iterable<string>): Set<string> {
|
|
176
|
+
const collected = new Set<string>();
|
|
177
|
+
for (const source of [this.configSource(), ...this.sources]) {
|
|
178
|
+
for (const role of pick(source) ?? []) {
|
|
179
|
+
if (typeof role === 'string' && role.length) {
|
|
180
|
+
collected.add(role);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return collected;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The process-wide registry.
|
|
190
|
+
*
|
|
191
|
+
* Module-level singleton in an import-free-ish leaf for the same reason the DI tokens live in
|
|
192
|
+
* leaves: it is read from `checkRestricted()`, which sits on the field-level authorization path and
|
|
193
|
+
* must not depend on the Nest container being available.
|
|
194
|
+
*/
|
|
195
|
+
export const roleScopeRegistry = new CoreRoleScopeRegistry();
|
|
@@ -10,12 +10,14 @@ import {
|
|
|
10
10
|
UnauthorizedException,
|
|
11
11
|
} from '@nestjs/common';
|
|
12
12
|
import { Reflector } from '@nestjs/core';
|
|
13
|
-
import {
|
|
13
|
+
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
14
14
|
import { InjectModel } from '@nestjs/mongoose';
|
|
15
15
|
import { Model } from 'mongoose';
|
|
16
16
|
|
|
17
17
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
18
|
+
import { resolveGuardRequest } from '../../common/helpers/execution-context-request.helper';
|
|
18
19
|
import { ConfigService } from '../../common/services/config.service';
|
|
20
|
+
import { ResolvedTenantContext, setTenantContextResolver } from '../../common/services/core-tenant-context.registry';
|
|
19
21
|
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
20
22
|
import { ErrorCode } from '../error-code/error-codes';
|
|
21
23
|
import { CoreTenantMemberModel } from './core-tenant-member.model';
|
|
@@ -27,6 +29,8 @@ import {
|
|
|
27
29
|
getRoleHierarchy,
|
|
28
30
|
isSystemRole,
|
|
29
31
|
mergeRolesMetadata,
|
|
32
|
+
resolveGlobalAndTenantRoles,
|
|
33
|
+
tenantSatisfiableRoles,
|
|
30
34
|
} from './core-tenant.helpers';
|
|
31
35
|
|
|
32
36
|
/**
|
|
@@ -155,6 +159,67 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
155
159
|
if (this.cleanupInterval.unref) {
|
|
156
160
|
this.cleanupInterval.unref();
|
|
157
161
|
}
|
|
162
|
+
|
|
163
|
+
// Make the membership logic reachable from transports that have no Express request — today the
|
|
164
|
+
// GraphQL WebSocket. See core-tenant-context.registry.ts for why this is a registry and not DI.
|
|
165
|
+
setTenantContextResolver({
|
|
166
|
+
resolve: (user, headerTenantId) => this.resolveTenantContext(user, headerTenantId),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Answer "which tenant is this caller in?" WITHOUT an Express request.
|
|
172
|
+
*
|
|
173
|
+
* The HTTP path answers this inside `canActivate()` and writes the result onto the request, which
|
|
174
|
+
* `RequestContextMiddleware` then publishes through AsyncLocalStorage. A WebSocket has neither, so
|
|
175
|
+
* this is the same decision reachable from a transport that only has a user and a header value.
|
|
176
|
+
*
|
|
177
|
+
* It deliberately does NOT decide ACCESS — that is `canActivate()`'s job, driven by `@Roles()`
|
|
178
|
+
* metadata this method cannot see. It only establishes the tenant SCOPE the Mongoose plugin
|
|
179
|
+
* filters by, so a read from such a transport is narrowed instead of running unscoped.
|
|
180
|
+
*
|
|
181
|
+
* A header naming a tenant the user is not an active member of yields NO tenant id rather than the
|
|
182
|
+
* requested one: the plugin then finds no tenant scope and its safety net refuses the read. An
|
|
183
|
+
* unvalidated header would be strictly worse than no header at all.
|
|
184
|
+
*/
|
|
185
|
+
async resolveTenantContext(
|
|
186
|
+
user: { id: string; roles?: string[] } | undefined,
|
|
187
|
+
headerTenantId?: string,
|
|
188
|
+
): Promise<ResolvedTenantContext> {
|
|
189
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
190
|
+
if (!config || config.enabled === false) {
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const trimmed =
|
|
195
|
+
headerTenantId && typeof headerTenantId === 'string' && headerTenantId.length <= 128
|
|
196
|
+
? headerTenantId.trim()
|
|
197
|
+
: undefined;
|
|
198
|
+
|
|
199
|
+
const adminBypass = config.adminBypass !== false;
|
|
200
|
+
if (adminBypass && user?.roles?.includes(RoleEnum.ADMIN)) {
|
|
201
|
+
// Mirrors canActivate(): with a header an admin is scoped to that tenant, without one they see
|
|
202
|
+
// everything. No membership is required either way.
|
|
203
|
+
return trimmed ? { isAdminBypass: true, tenantId: trimmed } : { isAdminBypass: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!user?.id) {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (trimmed) {
|
|
211
|
+
const membership = await this.findMembershipCached(user.id, trimmed);
|
|
212
|
+
if (!membership) {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
return { tenantId: trimmed, tenantRole: membership.role as string };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// No header: scope to every tenant the user is an active member of — the same fallback the HTTP
|
|
219
|
+
// path uses, so the plugin filters by `{ tenantId: { $in: tenantIds } }`.
|
|
220
|
+
const carrier: { tenantIds?: string[]; user: { id: string } } = { user: { id: user.id } };
|
|
221
|
+
await this.resolveUserTenantIds(carrier);
|
|
222
|
+
return { tenantIds: carrier.tenantIds ?? [] };
|
|
158
223
|
}
|
|
159
224
|
|
|
160
225
|
/**
|
|
@@ -425,9 +490,28 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
425
490
|
|
|
426
491
|
const memberRole = membership.role as string;
|
|
427
492
|
|
|
428
|
-
// Check role access if roles are required (hierarchy + normal, against membership.role)
|
|
493
|
+
// Check role access if roles are required (hierarchy + normal, against membership.role).
|
|
494
|
+
//
|
|
495
|
+
// Global-only roles (RoleEnum.ADMIN) are resolved against user.roles instead — a membership
|
|
496
|
+
// role is customer-assigned free text, so comparing it against the framework's global admin
|
|
497
|
+
// role by string equality would let any tenant owner mint platform-wide access by naming
|
|
498
|
+
// their tenant role 'admin'. The two authority levels stay separate:
|
|
499
|
+
// global admin → RoleEnum.ADMIN in user.roles (every tenant)
|
|
500
|
+
// tenant admin → a membership role such as 'tenantAdmin' (this tenant only)
|
|
501
|
+
//
|
|
502
|
+
// OR semantics across both halves, so @Roles(ADMIN, 'owner') keeps reading as alternatives.
|
|
429
503
|
if (checkableRoles.length > 0) {
|
|
430
|
-
|
|
504
|
+
const { global: globalRoles } = resolveGlobalAndTenantRoles(checkableRoles);
|
|
505
|
+
// Deny by default: only roles DECLARED as tenant-scoped may be satisfied by a membership.
|
|
506
|
+
// An undeclared role is not silently matched against customer-assigned free text.
|
|
507
|
+
const tenantRoles = tenantSatisfiableRoles(checkableRoles);
|
|
508
|
+
const satisfiedGlobally = globalRoles.some((r) => user.roles?.includes(r));
|
|
509
|
+
// The length guard is NOT redundant here (unlike the `.some()` above): checkRoleAccess
|
|
510
|
+
// returns TRUE for an empty required-roles list, so calling it with no tenant roles would
|
|
511
|
+
// grant access to a handler that only ever required a global role.
|
|
512
|
+
const satisfiedByTenant = tenantRoles.length > 0 && checkRoleAccess(tenantRoles, undefined, memberRole);
|
|
513
|
+
|
|
514
|
+
if (!satisfiedGlobally && !satisfiedByTenant) {
|
|
431
515
|
throw new ForbiddenException('Insufficient tenant role');
|
|
432
516
|
}
|
|
433
517
|
}
|
|
@@ -538,15 +622,11 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
538
622
|
* Extract request from GraphQL or HTTP context
|
|
539
623
|
*/
|
|
540
624
|
private getRequest(context: ExecutionContext): any {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return context.switchToHttp().getRequest();
|
|
547
|
-
} catch {
|
|
548
|
-
return null;
|
|
549
|
-
}
|
|
625
|
+
// Shared with both role guards — see execution-context-request.helper for why the GraphQL
|
|
626
|
+
// WEBSOCKET branch is load-bearing: without it this method answered `null` for every
|
|
627
|
+
// subscription, `canActivate()` hit `if (!request) return true`, and because the role guard
|
|
628
|
+
// delegates non-system roles here, `@Roles()` on a subscription was enforced by nobody.
|
|
629
|
+
return resolveGuardRequest(context, (ctx) => GqlExecutionContext.create(ctx).getContext()) ?? null;
|
|
550
630
|
}
|
|
551
631
|
|
|
552
632
|
// ===================================================================================================================
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { isForbiddenMembershipRole, isGlobalOnlyRole, looksLikeSystemRole } from '../../common/enums/role.enum';
|
|
1
2
|
import { ConfigService } from '../../common/services/config.service';
|
|
3
|
+
import { RoleScope, roleScopeRegistry, RoleScopeSource } from './core-role-scope.registry';
|
|
2
4
|
import { DEFAULT_ROLE_HIERARCHY } from './core-tenant.enums';
|
|
3
5
|
|
|
4
|
-
const SYSTEM_ROLE_PREFIX = 's_';
|
|
5
|
-
|
|
6
6
|
/**
|
|
7
7
|
* Merge handler-level and class-level @Roles() metadata arrays into a single flat array.
|
|
8
8
|
* Used by RolesGuard, BetterAuthRolesGuard, and CoreTenantGuard.
|
|
@@ -23,13 +23,145 @@ export function getRoleHierarchy(): Record<string, number> {
|
|
|
23
23
|
return ConfigService.configFastButReadOnly?.multiTenancy?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// `isSystemRole` used to be defined here. It now lives in `common/enums/role.enum.ts`, next to the
|
|
27
|
+
// RoleEnum members it describes, so the storage guards and the runtime guards share ONE predicate
|
|
28
|
+
// instead of drifting apart (they already disagreed on case). Re-exported below for compatibility.
|
|
29
|
+
export {
|
|
30
|
+
GLOBAL_ONLY_ROLES,
|
|
31
|
+
isForbiddenMembershipRole,
|
|
32
|
+
isGlobalOnlyRole,
|
|
33
|
+
isSystemRole,
|
|
34
|
+
looksLikeGlobalOnlyRole,
|
|
35
|
+
looksLikeSystemRole,
|
|
36
|
+
SYSTEM_ROLE_PREFIX,
|
|
37
|
+
} from '../../common/enums/role.enum';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Feeds the role-scope registry from `multiTenancy` config.
|
|
41
|
+
*
|
|
42
|
+
* Reads the config lazily on every call rather than caching, so a `ConfigService.setConfig()` in a
|
|
43
|
+
* test (or a future runtime reload) is picked up without re-registering the source.
|
|
44
|
+
*/
|
|
45
|
+
export const configRoleScopeSource: RoleScopeSource = {
|
|
46
|
+
globalRoles: () => ConfigService.configFastButReadOnly?.multiTenancy?.globalOnlyRoles ?? [],
|
|
47
|
+
tenantRoles: () => {
|
|
48
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
49
|
+
return [
|
|
50
|
+
...Object.keys(config?.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY),
|
|
51
|
+
...(config?.additionalMembershipRoles ?? []),
|
|
52
|
+
];
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate the configured role vocabulary. Throws when it cannot be enforced coherently.
|
|
58
|
+
*
|
|
59
|
+
* Called at boot (`CoreTenantModule`). Failing the boot is the right severity: every condition here
|
|
60
|
+
* describes a configuration whose access decisions would be ambiguous or silently wrong, and an
|
|
61
|
+
* ambiguous authorization rule is worse than a server that refuses to start.
|
|
62
|
+
*/
|
|
63
|
+
export function assertRoleVocabularyIsCoherent(): void {
|
|
64
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
65
|
+
if (!config || config.enabled === false) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const hierarchy = Object.keys(config.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY);
|
|
70
|
+
const additional = config.additionalMembershipRoles ?? [];
|
|
71
|
+
const declaredGlobal = config.globalOnlyRoles ?? [];
|
|
72
|
+
|
|
73
|
+
// 1. A tenant role must never be named after a framework role.
|
|
74
|
+
const reserved = [...hierarchy, ...additional].filter((role) => isForbiddenMembershipRole(role));
|
|
75
|
+
if (reserved.length) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`multiTenancy: tenant role(s) [${reserved.join(', ')}] use a reserved framework role name. ` +
|
|
78
|
+
'A tenant role of that name would be compared against the framework role by exact string match, ' +
|
|
79
|
+
'letting a tenant owner grant themselves platform authority. Rename them (e.g. "tenantAdmin") ' +
|
|
80
|
+
'and declare platform-wide roles via multiTenancy.globalOnlyRoles.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. A role cannot be global and tenant-scoped at once — it would need two sources of truth.
|
|
85
|
+
const both = declaredGlobal.filter((role) => hierarchy.includes(role) || additional.includes(role));
|
|
86
|
+
if (both.length) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`multiTenancy: role(s) [${both.join(', ')}] are declared BOTH in globalOnlyRoles and as tenant roles. ` +
|
|
89
|
+
'Each role must resolve against exactly one source (user.roles for global, membership.role for tenant).',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. A declared global role must not be a system role either.
|
|
94
|
+
const systemGlobals = declaredGlobal.filter((role) => looksLikeSystemRole(role));
|
|
95
|
+
if (systemGlobals.length) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`multiTenancy.globalOnlyRoles contains system role(s) [${systemGlobals.join(', ')}]. ` +
|
|
98
|
+
'System roles are runtime-context checks and are never stored or granted.',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
26
103
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
104
|
+
* Split required roles into the ones only the PLATFORM can satisfy and the ones a TENANT can.
|
|
105
|
+
*
|
|
106
|
+
* This split is the tenant boundary. Membership roles are customer-assigned free text, and in
|
|
107
|
+
* tenant context they are compared against required roles by exact string match — so without the
|
|
108
|
+
* split, a member whose tenant role is literally `'admin'` satisfies `@Roles(RoleEnum.ADMIN)`, the
|
|
109
|
+
* global platform role. Anyone allowed to manage members (a tenant owner, i.e. a customer) could
|
|
110
|
+
* mint platform-wide access for themselves.
|
|
111
|
+
*
|
|
112
|
+
* Callers must resolve each half against its own source:
|
|
113
|
+
* - `global` → `user.roles` (never the membership role)
|
|
114
|
+
* - `tenant` → `membership.role` in tenant context, `user.roles` otherwise
|
|
115
|
+
*
|
|
116
|
+
* OR semantics across the two halves: satisfying either is enough, which keeps
|
|
117
|
+
* `@Roles(ADMIN, 'owner')` working as the alternative it reads as.
|
|
30
118
|
*/
|
|
31
|
-
export function
|
|
32
|
-
|
|
119
|
+
export function resolveGlobalAndTenantRoles(requiredRoles: string[]): { global: string[]; tenant: string[] } {
|
|
120
|
+
const global: string[] = [];
|
|
121
|
+
const tenant: string[] = [];
|
|
122
|
+
for (const role of requiredRoles ?? []) {
|
|
123
|
+
// The registry knows framework roles, project-declared global roles and configured tenant
|
|
124
|
+
// roles. `isGlobalOnlyRole` is kept as the floor so RoleEnum.ADMIN is global even before any
|
|
125
|
+
// source is registered (e.g. a unit test constructing the helper in isolation).
|
|
126
|
+
const scope = roleScopeRegistry.scopeOf(role);
|
|
127
|
+
(scope === RoleScope.GLOBAL || isGlobalOnlyRole(role) ? global : tenant).push(role);
|
|
128
|
+
}
|
|
129
|
+
return { global, tenant };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Which required roles may a TENANT MEMBERSHIP satisfy?
|
|
134
|
+
*
|
|
135
|
+
* Two tiers, and the distinction is the whole design:
|
|
136
|
+
*
|
|
137
|
+
* 1. **Unconditional** — roles with GLOBAL scope are never satisfiable by a membership. This is the
|
|
138
|
+
* security fix: a customer-assigned role must not answer for the platform. Always on.
|
|
139
|
+
* 2. **Opt-in** (`multiTenancy.strictMembershipRoles`) — narrow further to roles the project
|
|
140
|
+
* DECLARED as tenant-scoped, so an undeclared name cannot line up with a membership by
|
|
141
|
+
* coincidence.
|
|
142
|
+
*
|
|
143
|
+
* Tier 2 is not the default on purpose. A project may legitimately use exact-match roles that
|
|
144
|
+
* appear only in `@Roles()` and in its membership data and never in `roleHierarchy`; denying those
|
|
145
|
+
* at the guard by default would be a silent, fleet-wide lockout — a worse failure than the case it
|
|
146
|
+
* guards against, which tier 1 already covers as soon as the role is declared global.
|
|
147
|
+
*/
|
|
148
|
+
export function tenantSatisfiableRoles(requiredRoles: string[]): string[] {
|
|
149
|
+
// Always excluded: roles whose authority is global. That is the security fix, and it is
|
|
150
|
+
// unconditional — a customer-assigned membership role must never answer for the platform.
|
|
151
|
+
const candidates = resolveGlobalAndTenantRoles(requiredRoles).tenant;
|
|
152
|
+
|
|
153
|
+
// Beyond that, opt-in. `strictMembershipRoles` narrows matching to roles the project actually
|
|
154
|
+
// DECLARED, so an undeclared name cannot line up with a membership by coincidence.
|
|
155
|
+
//
|
|
156
|
+
// It is deliberately NOT the default: a project may legitimately use exact-match roles that
|
|
157
|
+
// appear only in @Roles() and in its membership data, never in roleHierarchy. Denying those by
|
|
158
|
+
// default would break such setups at the guard — a silent, fleet-wide lockout — for a case the
|
|
159
|
+
// global/tenant split already covers as soon as the role is declared global.
|
|
160
|
+
if (!ConfigService.configFastButReadOnly?.multiTenancy?.strictMembershipRoles) {
|
|
161
|
+
return candidates;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return candidates.filter((role) => roleScopeRegistry.isTenantRole(role));
|
|
33
165
|
}
|
|
34
166
|
|
|
35
167
|
/**
|
|
@@ -3,8 +3,10 @@ import { APP_GUARD } from '@nestjs/core';
|
|
|
3
3
|
import { MongooseModule, SchemaFactory, getModelToken } from '@nestjs/mongoose';
|
|
4
4
|
import { Model } from 'mongoose';
|
|
5
5
|
|
|
6
|
+
import { roleScopeRegistry } from './core-role-scope.registry';
|
|
6
7
|
import { CoreTenantMemberModel } from './core-tenant-member.model';
|
|
7
8
|
import { TENANT_MEMBER_MODEL_TOKEN } from './core-tenant.enums';
|
|
9
|
+
import { assertRoleVocabularyIsCoherent, configRoleScopeSource } from './core-tenant.helpers';
|
|
8
10
|
import { CoreTenantGuard } from './core-tenant.guard';
|
|
9
11
|
import { CoreTenantService } from './core-tenant.service';
|
|
10
12
|
|
|
@@ -58,6 +60,16 @@ export interface CoreTenantModuleOptions {
|
|
|
58
60
|
@Module({})
|
|
59
61
|
export class CoreTenantModule {
|
|
60
62
|
static forRoot(options: CoreTenantModuleOptions = {}): DynamicModule {
|
|
63
|
+
// Teach the role-scope registry which roles are global and which are tenant-scoped, then
|
|
64
|
+
// refuse to boot on a vocabulary that cannot be enforced coherently (a tenant role named after
|
|
65
|
+
// a framework role, or one role declared in both scopes). An ambiguous authorization rule is
|
|
66
|
+
// worse than a server that does not start, so this throws rather than warns.
|
|
67
|
+
//
|
|
68
|
+
// Registration is idempotent: forRoot() may run more than once across test module fixtures.
|
|
69
|
+
roleScopeRegistry.clear();
|
|
70
|
+
roleScopeRegistry.register(configRoleScopeSource);
|
|
71
|
+
assertRoleVocabularyIsCoherent();
|
|
72
|
+
|
|
61
73
|
const MemberModel = options.memberModel || CoreTenantMemberModel;
|
|
62
74
|
const Guard = options.guard || CoreTenantGuard;
|
|
63
75
|
const Service = options.service || CoreTenantService;
|
|
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Optional }
|
|
|
2
2
|
import { InjectModel } from '@nestjs/mongoose';
|
|
3
3
|
import { Model } from 'mongoose';
|
|
4
4
|
|
|
5
|
+
import { looksLikeGlobalOnlyRole, looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
|
|
5
6
|
import { ConfigService } from '../../common/services/config.service';
|
|
6
7
|
import { RequestContext } from '../../common/services/request-context.service';
|
|
7
8
|
import { CoreTenantMemberModel } from './core-tenant-member.model';
|
|
@@ -101,6 +102,7 @@ export class CoreTenantService {
|
|
|
101
102
|
throw new BadRequestException('userId must not be empty');
|
|
102
103
|
}
|
|
103
104
|
const effectiveRole = role ?? this.getDefaultRole();
|
|
105
|
+
assertAssignableMembershipRole(effectiveRole);
|
|
104
106
|
|
|
105
107
|
// Check for existing membership
|
|
106
108
|
const existing = await this.getMembership(tenantId, userId);
|
|
@@ -188,6 +190,7 @@ export class CoreTenantService {
|
|
|
188
190
|
if (!role?.trim()) {
|
|
189
191
|
throw new BadRequestException('role must not be empty');
|
|
190
192
|
}
|
|
193
|
+
assertAssignableMembershipRole(role);
|
|
191
194
|
const highestRole = this.getHighestRole();
|
|
192
195
|
|
|
193
196
|
// If demoting from highest role, ensure it's not the last one
|
|
@@ -242,3 +245,56 @@ export class CoreTenantService {
|
|
|
242
245
|
});
|
|
243
246
|
}
|
|
244
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Refuse a membership role that would cross the tenant boundary.
|
|
251
|
+
*
|
|
252
|
+
* Membership roles are customer-assigned free text, and whoever may manage members is typically a
|
|
253
|
+
* tenant owner — a customer. Two families of name must never become one:
|
|
254
|
+
*
|
|
255
|
+
* - **system roles** (`s_*`) are runtime-context questions ("is this the owner of the record?"),
|
|
256
|
+
* not stored roles. A membership named `s_self` used to satisfy `@Restricted(S_SELF)` on
|
|
257
|
+
* arbitrary records.
|
|
258
|
+
* - **global-only roles** (`RoleEnum.ADMIN`) are platform authority. A membership named `admin`
|
|
259
|
+
* used to satisfy `@Roles(RoleEnum.ADMIN)` — the global role — inside tenant context.
|
|
260
|
+
*
|
|
261
|
+
* This is the SECOND layer, not the protection itself. The guards resolve each required role
|
|
262
|
+
* against its own source (`user.roles` vs `membership.role`), so an already-stored dangerous name
|
|
263
|
+
* is inert even without this check — which matters, because a future `RoleEnum` addition would
|
|
264
|
+
* otherwise turn every pre-existing membership of that name into a hole retroactively. This check
|
|
265
|
+
* only stops new ones from being created, and gives a clear error instead of silent inertness.
|
|
266
|
+
*/
|
|
267
|
+
export function assertAssignableMembershipRole(role: string): void {
|
|
268
|
+
if (looksLikeSystemRole(role)) {
|
|
269
|
+
throw new BadRequestException(
|
|
270
|
+
`A system role (${SYSTEM_ROLE_PREFIX}*) must never be used as a tenant membership role: ${role}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (looksLikeGlobalOnlyRole(role)) {
|
|
274
|
+
throw new BadRequestException(
|
|
275
|
+
`"${role}" is a global role and must never be used as a tenant membership role — ` +
|
|
276
|
+
'use a tenant-specific name such as "tenantAdmin" instead',
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Deny by default, when enabled: only roles the project actually declared.
|
|
281
|
+
//
|
|
282
|
+
// An undeclared role can never GRANT anything either way — the guards match only declared tenant
|
|
283
|
+
// roles — so this does not change access decisions. What it changes is WHEN the mistake surfaces:
|
|
284
|
+
// as a 400 at assignment time, instead of as a membership that silently authorizes nothing while
|
|
285
|
+
// looking perfectly fine in a members list.
|
|
286
|
+
const config = ConfigService.configFastButReadOnly?.multiTenancy;
|
|
287
|
+
if (config?.strictMembershipRoles) {
|
|
288
|
+
const declared = new Set([
|
|
289
|
+
...Object.keys(config.roleHierarchy ?? DEFAULT_ROLE_HIERARCHY),
|
|
290
|
+
...(config.additionalMembershipRoles ?? []),
|
|
291
|
+
]);
|
|
292
|
+
if (!declared.has(role)) {
|
|
293
|
+
throw new BadRequestException(
|
|
294
|
+
`"${role}" is not a declared tenant role. Declared: [${[...declared].sort().join(', ')}]. ` +
|
|
295
|
+
'Add it to multiTenancy.roleHierarchy or multiTenancy.additionalMembershipRoles, ' +
|
|
296
|
+
'or disable multiTenancy.strictMembershipRoles.',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -380,13 +380,52 @@ query {
|
|
|
380
380
|
|
|
381
381
|
The following metadata is stored with each GridFS file:
|
|
382
382
|
|
|
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
|
-
| `
|
|
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
|
+
| `uploadedAt` | Completion timestamp |
|
|
391
|
+
|
|
392
|
+
### Upload ownership (11.35.0+)
|
|
393
|
+
|
|
394
|
+
`tus.roles` decides **who may reach the endpoint**. It says nothing about **which upload** a caller may
|
|
395
|
+
touch — and the protocol is built around a per-upload URL: after `POST /tus` the client holds
|
|
396
|
+
`/tus/<id>` and uses it for `HEAD` (offset), `PATCH` (append bytes) and `DELETE` (terminate). Until
|
|
397
|
+
11.35.0 all three carried only that coarse gate, so any other authenticated caller who learned an id
|
|
398
|
+
could resume, **overwrite** or destroy somebody else's upload. Overwriting is the sharp end: the bytes
|
|
399
|
+
are migrated into the file store under the ORIGINAL uploader's filename.
|
|
400
|
+
|
|
401
|
+
Two things changed:
|
|
402
|
+
|
|
403
|
+
- **`onUploadCreate` records the creator** in the upload's own metadata under
|
|
404
|
+
`TUS_OWNER_METADATA_KEY` (`ltOwnerId`). It **overwrites** any client-supplied value — metadata
|
|
405
|
+
arrives in the `Upload-Metadata` header, so a merged value would let a caller name somebody else as
|
|
406
|
+
the owner.
|
|
407
|
+
- **`onIncomingRequest` refuses a request naming an upload the caller does not own**, with **404** — the
|
|
408
|
+
same "a refusal is indistinguishable from a missing resource" policy the file module uses, so the
|
|
409
|
+
endpoint is not an existence oracle for upload ids.
|
|
410
|
+
|
|
411
|
+
The finished file's metadata gains `ownerId`, which is the key
|
|
412
|
+
`CoreFileService.checkRights()` documents — so a tus-uploaded file can finally satisfy a per-file
|
|
413
|
+
ownership rule. Before this it could not: the rule failed closed for everyone but ADMIN, and a project
|
|
414
|
+
following the documented pattern ended up with files nobody could download.
|
|
415
|
+
|
|
416
|
+
**An owner-LESS upload stays reachable by anyone who may reach the endpoint.** Deliberately: uploads
|
|
417
|
+
created before 11.35.0 carry no owner, and neither does an intentionally public form
|
|
418
|
+
(`tus.roles: [RoleEnum.S_EVERYONE]`). Denying those would break in-flight uploads on upgrade and a
|
|
419
|
+
documented configuration. What is closed is an upload that HAS an owner being touched by somebody else.
|
|
420
|
+
|
|
421
|
+
Both `readRequestUserId()` and `assertUploadOwnership()` are `protected` — override to read the owner
|
|
422
|
+
from elsewhere (an API key, a signed form token), or to let a support role resume any upload.
|
|
423
|
+
|
|
424
|
+
> **Note for a custom service:** `@tus/server` v2 does NOT hand the Express request to its hooks. It
|
|
425
|
+
> converts the Node request into a WHATWG `ServerRequest` first, so anything a guard attached lives on
|
|
426
|
+
> the original request, reachable through `runtime.node.req` — which is why `readRequestUserId()` checks
|
|
427
|
+
> there as well. Reading only `req.user` finds nothing and every upload silently becomes owner-less,
|
|
428
|
+
> failing in the permissive direction.
|
|
390
429
|
|
|
391
430
|
---
|
|
392
431
|
|