@lenne.tech/nest-server 11.34.0 → 11.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/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/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/helpers/validation-message.helper.d.ts +3 -0
- package/dist/core/common/helpers/validation-message.helper.js +41 -0
- package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
- 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/pipes/map-and-validate.pipe.js +16 -6
- package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
- 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.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 +10 -0
- package/dist/core/modules/file/file-roles.config.js +78 -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 +8 -20
- 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 +10 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -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.0-to-11.34.1.md +132 -0
- package/migration-guides/11.34.x-to-11.35.x.md +832 -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/graphql-ws-context.helper.ts +192 -0
- package/src/core/common/helpers/gridfs.helper.ts +18 -2
- package/src/core/common/helpers/validation-message.helper.ts +83 -0
- 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/pipes/map-and-validate.pipe.ts +25 -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 +61 -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 +202 -0
- package/src/core/modules/file/file-roles.helper.ts +11 -40
- 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 +62 -1
- package/src/index.ts +3 -0
- package/src/server/modules/file/file.service.ts +45 -2
- package/src/test/test.helper.ts +54 -11
|
@@ -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
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
FilesystemFileHelper,
|
|
19
19
|
} from '../file/filesystem-file.helper';
|
|
20
20
|
import { S3_FILES_COLLECTION, S3FileHelper } from '../file/s3-file.helper';
|
|
21
|
+
import { TUS_OWNER_METADATA_KEY } from './tus.constants';
|
|
21
22
|
import { TusRedisLocker } from './tus-redis-locker';
|
|
22
23
|
import {
|
|
23
24
|
DEFAULT_TUS_ALLOWED_HEADERS,
|
|
@@ -181,6 +182,11 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
181
182
|
const contentType = metadata.filetype || 'application/octet-stream';
|
|
182
183
|
const fileMetadata = {
|
|
183
184
|
originalMetadata: metadata,
|
|
185
|
+
// The key `CoreFileService.checkRights()` documents for a per-file ownership rule. Without it
|
|
186
|
+
// a tus-uploaded file could never satisfy that rule — it failed CLOSED for everyone but
|
|
187
|
+
// ADMIN, so a project following the documented pattern ended up with undownloadable files.
|
|
188
|
+
// Written from the SERVER-recorded owner, never from the client's own metadata.
|
|
189
|
+
...(metadata[TUS_OWNER_METADATA_KEY] ? { ownerId: metadata[TUS_OWNER_METADATA_KEY] } : {}),
|
|
184
190
|
tusUploadId: upload.id,
|
|
185
191
|
uploadedAt: new Date(),
|
|
186
192
|
};
|
|
@@ -460,6 +466,79 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
460
466
|
await this.deleteStagedUpload(upload.id);
|
|
461
467
|
}
|
|
462
468
|
|
|
469
|
+
/**
|
|
470
|
+
* The authenticated caller's id, as the upstream role guard left it on the request.
|
|
471
|
+
*
|
|
472
|
+
* `CoreTusController.handleTus(req, res)` receives the Express request, so `req.user` is whatever
|
|
473
|
+
* the guard put there. But `@tus/server` v2 does NOT hand that object to its hooks: `handle()`
|
|
474
|
+
* converts the Node request into a WHATWG `ServerRequest` (srvx) first, and anything the guard
|
|
475
|
+
* attached lives on the ORIGINAL request, reachable through `runtime.node.req`. Reading only
|
|
476
|
+
* `req.user` therefore finds nothing and every upload silently becomes owner-less — which fails in
|
|
477
|
+
* the permissive direction, so it would not have shown up as an error anywhere.
|
|
478
|
+
*
|
|
479
|
+
* Returns `undefined` for an unauthenticated request, which is a legitimate state: a project may
|
|
480
|
+
* open the gate with `tus.roles: [RoleEnum.S_EVERYONE]` for a public form.
|
|
481
|
+
*
|
|
482
|
+
* Override to read the owner from somewhere else (an API key, a signed form token).
|
|
483
|
+
*/
|
|
484
|
+
protected readRequestUserId(req: any): string | undefined {
|
|
485
|
+
const id = req?.user?.id ?? req?.runtime?.node?.req?.user?.id ?? req?.context?.user?.id;
|
|
486
|
+
return id === undefined || id === null || id === '' ? undefined : String(id);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Refuse a request that names an upload the caller does not own.
|
|
491
|
+
*
|
|
492
|
+
* `tus.roles` is the coarse gate — who may reach the endpoint at all. This is the fine one: HEAD
|
|
493
|
+
* (read the offset), PATCH (APPEND BYTES) and DELETE (terminate) all address an upload by its URL,
|
|
494
|
+
* and without this any authenticated caller who learns an id could resume, overwrite or destroy
|
|
495
|
+
* somebody else's upload. Appending is the sharpest: those bytes are then migrated into the file
|
|
496
|
+
* store under the ORIGINAL uploader's filename, so the attacker's content is served as the victim's.
|
|
497
|
+
*
|
|
498
|
+
* **Refuses with 404, never 403** — the same policy `CoreFileService.checkRights()` states: a
|
|
499
|
+
* refusal must be indistinguishable from an upload that does not exist, or the endpoint becomes an
|
|
500
|
+
* existence oracle for ids that are not secrets.
|
|
501
|
+
*
|
|
502
|
+
* **An OWNER-LESS upload stays reachable by anyone who may reach the endpoint.** That is deliberate
|
|
503
|
+
* and load-bearing for backward compatibility: uploads created before 11.35.0 carry no owner, and
|
|
504
|
+
* so does every upload of a project that deliberately runs the endpoint public. Denying those would
|
|
505
|
+
* turn an upgrade into a fleet-wide breakage of in-flight uploads and break a documented
|
|
506
|
+
* configuration. What is closed is the case that actually leaks: an upload that HAS an owner being
|
|
507
|
+
* touched by somebody else.
|
|
508
|
+
*
|
|
509
|
+
* Override to widen this — e.g. to let a support role resume any upload.
|
|
510
|
+
*/
|
|
511
|
+
protected async assertUploadOwnership(req: any, uploadId: string): Promise<void> {
|
|
512
|
+
// No id: this is a creation (POST) or a server-capability request (OPTIONS). Nothing to own yet.
|
|
513
|
+
if (!uploadId) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let owner: string | undefined;
|
|
518
|
+
try {
|
|
519
|
+
const upload = await (this.tusServer as any)?.datastore?.getUpload?.(uploadId);
|
|
520
|
+
const recorded = upload?.metadata?.[TUS_OWNER_METADATA_KEY];
|
|
521
|
+
owner = recorded === undefined || recorded === null ? undefined : String(recorded);
|
|
522
|
+
} catch {
|
|
523
|
+
// Unknown id, or a store that cannot answer right now. Say nothing and let the tus server
|
|
524
|
+
// produce its own 404 — inventing a refusal here would also refuse legitimate retries during a
|
|
525
|
+
// transient store outage.
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!owner) {
|
|
530
|
+
return; // owner-less upload — see the note above
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const caller = this.readRequestUserId(req);
|
|
534
|
+
if (caller && caller === owner) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.logger.warn(`Refused tus request for upload ${uploadId}: caller is not its owner`);
|
|
539
|
+
throw Object.assign(new Error('Upload not found'), { body: 'Upload not found', status_code: 404 });
|
|
540
|
+
}
|
|
541
|
+
|
|
463
542
|
/**
|
|
464
543
|
* Validate file type against allowedTypes configuration
|
|
465
544
|
*
|
|
@@ -519,11 +598,20 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
519
598
|
datastore,
|
|
520
599
|
...(locker ? { locker } : {}),
|
|
521
600
|
maxSize: this.config.maxSize,
|
|
522
|
-
|
|
601
|
+
// Every request that names an upload — HEAD (offset), PATCH (append bytes), DELETE
|
|
602
|
+
// (terminate) — is checked against the upload's recorded owner. `tus.roles` alone cannot do
|
|
603
|
+
// this: it decides who may reach the endpoint, not which upload they may touch, and the tus
|
|
604
|
+
// protocol is built around a per-upload URL. Appending is the sharp end — bytes PATCHed into
|
|
605
|
+
// somebody else's upload are migrated into the file store under THEIR filename.
|
|
606
|
+
onIncomingRequest: async (req, uploadId) => this.assertUploadOwnership(req, uploadId),
|
|
607
|
+
onUploadCreate: async (req, upload) => {
|
|
608
|
+
// Record the creator FIRST, so the ownership check below has something to compare against.
|
|
609
|
+
// Overwrites rather than merges: see TUS_OWNER_METADATA_KEY.
|
|
610
|
+
const metadata = { ...upload.metadata, [TUS_OWNER_METADATA_KEY]: this.readRequestUserId(req) ?? null };
|
|
611
|
+
|
|
523
612
|
// Validate file type if allowedTypes is configured
|
|
524
613
|
if (this.config.allowedTypes && this.config.allowedTypes.length > 0) {
|
|
525
|
-
const
|
|
526
|
-
const filetype = metadata.filetype;
|
|
614
|
+
const filetype = this.parseMetadata(upload.metadata).filetype;
|
|
527
615
|
|
|
528
616
|
if (!this.validateFileType(filetype)) {
|
|
529
617
|
const allowedList = this.config.allowedTypes.join(', ');
|
|
@@ -541,8 +629,9 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
541
629
|
}
|
|
542
630
|
}
|
|
543
631
|
|
|
544
|
-
//
|
|
545
|
-
|
|
632
|
+
// Returning the metadata is what PERSISTS the owner — the tus server writes the returned
|
|
633
|
+
// value onto the upload. Returning `{}` here would keep the client's own metadata verbatim.
|
|
634
|
+
return { metadata };
|
|
546
635
|
},
|
|
547
636
|
onUploadFinish: async (_req, upload) => {
|
|
548
637
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dependency-injection tokens of the TUS module.
|
|
2
|
+
* Dependency-injection tokens AND cross-file constants of the TUS module.
|
|
3
3
|
*
|
|
4
4
|
* They live in a dedicated, import-free leaf file — never in `tus.module.ts` or a service — so that
|
|
5
5
|
* no file needing a token has to import the module (or vice versa) and close an import cycle.
|
|
@@ -23,3 +23,19 @@
|
|
|
23
23
|
* Injected type: `Required<ITusConfig>`.
|
|
24
24
|
*/
|
|
25
25
|
export const TUS_CONFIG = 'TUS_CONFIG';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Metadata key recording WHO created a tus upload, in the upload's own (staged) metadata.
|
|
29
|
+
*
|
|
30
|
+
* Framework-owned and ALWAYS overwritten in `onUploadCreate` — metadata otherwise arrives from the
|
|
31
|
+
* client in the `Upload-Metadata` header, so a merged value would let a caller declare somebody else
|
|
32
|
+
* as the owner and hand themselves access to the victim's upload URL.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately NOT `ownerId`: that is the key of the FINISHED FILE's metadata (the one
|
|
35
|
+
* `CoreFileService.checkRights()` documents), and keeping the two distinct means a project's own
|
|
36
|
+
* `ownerId` metadata on a file cannot be confused with the upload-time record.
|
|
37
|
+
*
|
|
38
|
+
* It lives here rather than in the service for the same reason the token above does — and because
|
|
39
|
+
* `tests/unit/import-cycle-invariants.spec.ts` enforces exactly that placement.
|
|
40
|
+
*/
|
|
41
|
+
export const TUS_OWNER_METADATA_KEY = 'ltOwnerId';
|
|
@@ -4,6 +4,7 @@ import crypto = require('crypto');
|
|
|
4
4
|
import { sha256 } from 'js-sha256';
|
|
5
5
|
import { Document, Model } from 'mongoose';
|
|
6
6
|
|
|
7
|
+
import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
|
|
7
8
|
import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
8
9
|
import { ServiceOptions } from '../../common/interfaces/service-options.interface';
|
|
9
10
|
import { ConfigService } from '../../common/services/config.service';
|
|
@@ -263,6 +264,17 @@ export abstract class CoreUserService<
|
|
|
263
264
|
throw new BadRequestException('Roles contains invalid values');
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
// Reject system roles (s_*). This is the framework's canonical "assign roles" API and it writes
|
|
268
|
+
// straight through findByIdAndUpdate, so neither MapAndValidatePipe nor check() sees the values.
|
|
269
|
+
// mongooseSystemRolePlugin is the backstop below this; the explicit check here exists to fail
|
|
270
|
+
// before the DB round-trip and with a message naming the offending values.
|
|
271
|
+
const systemRoles = roles.filter((role) => looksLikeSystemRole(role));
|
|
272
|
+
if (systemRoles.length) {
|
|
273
|
+
throw new BadRequestException(
|
|
274
|
+
`System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles: ${systemRoles.join(', ')}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
266
278
|
// Update and return user
|
|
267
279
|
return this.process(
|
|
268
280
|
async () => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { InputType } from '@nestjs/graphql';
|
|
2
|
-
import { IsEmail } from 'class-validator';
|
|
2
|
+
import { IsEmail, IsString, Matches } from 'class-validator';
|
|
3
3
|
|
|
4
4
|
import { Restricted } from '../../../common/decorators/restricted.decorator';
|
|
5
5
|
import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
|
|
6
6
|
import { ProcessType } from '../../../common/enums/process-type.enum';
|
|
7
|
-
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
7
|
+
import { RoleEnum, SYSTEM_ROLE_PREFIX, SYSTEM_ROLE_REJECT_PATTERN } from '../../../common/enums/role.enum';
|
|
8
8
|
import { CoreInput } from '../../../common/inputs/core-input.input';
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -50,12 +50,38 @@ export abstract class CoreUserInput extends CoreInput {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Roles of the user
|
|
53
|
+
*
|
|
54
|
+
* System roles (`s_*` prefix, see RoleEnum) are runtime-context checks and must NEVER be stored:
|
|
55
|
+
* `hasRole` is a plain string intersection, so a stored `s_self` would satisfy every S_SELF
|
|
56
|
+
* check (e.g. update/delete of ARBITRARY users) without the account carrying a real role.
|
|
57
|
+
* The @Restricted above limits WHO may send the field; this validator limits WHAT it may hold.
|
|
58
|
+
*
|
|
59
|
+
* A rejected value answers HTTP 400 (`Validation failed for 1 field: roles (matches)`, with the
|
|
60
|
+
* per-field message in the response body). `CoreUserCreateInput` inherits this field, so sign-up
|
|
61
|
+
* and user creation are covered too.
|
|
62
|
+
*
|
|
63
|
+
* This validator is the FIRST of three layers, not the only one — it gives the best error
|
|
64
|
+
* message, but a subclass that redeclares `roles` replaces it (MapAndValidatePipe walks the
|
|
65
|
+
* prototype chain child-first and skips a property once a child class has validated it), and
|
|
66
|
+
* `force: true` / direct Mongoose writes never reach it at all. `CoreUserService.setRoles()` and
|
|
67
|
+
* the unconditional `mongooseSystemRolePlugin` close those paths.
|
|
53
68
|
*/
|
|
54
69
|
@Restricted({ processType: ProcessType.INPUT, roles: RoleEnum.ADMIN })
|
|
55
70
|
@UnifiedField({
|
|
71
|
+
description: 'Roles of the user (system roles with s_ prefix are rejected)',
|
|
56
72
|
isArray: true,
|
|
57
73
|
isOptional: true,
|
|
58
74
|
type: String,
|
|
75
|
+
// `opts` already carries `each: true` for array fields — do not hardcode it, or array-ness has
|
|
76
|
+
// two sources of truth. Note that supplying `validator` REPLACES the built-in `IsString`, so it
|
|
77
|
+
// is re-declared here explicitly rather than relying on `matches()` rejecting non-strings.
|
|
78
|
+
validator: (opts) => [
|
|
79
|
+
IsString(opts),
|
|
80
|
+
Matches(SYSTEM_ROLE_REJECT_PATTERN, {
|
|
81
|
+
...opts,
|
|
82
|
+
message: `System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles`,
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
59
85
|
})
|
|
60
86
|
roles?: string[] = undefined;
|
|
61
87
|
|