@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
|
@@ -855,6 +855,41 @@ export interface IErrorCode {
|
|
|
855
855
|
* therefore be expressed in `checkRights()`, not by a role name alone.
|
|
856
856
|
*/
|
|
857
857
|
export interface IFileConfig {
|
|
858
|
+
/**
|
|
859
|
+
* WHICH PROJECT CLASS this deployment is — the per-file rule, as a declaration instead of code.
|
|
860
|
+
*
|
|
861
|
+
* The role knobs below are the coarse audience filter ("may this caller reach the route at all").
|
|
862
|
+
* They cannot express "…but only their own", because that sentence needs data. That is what
|
|
863
|
+
* `CoreFileService.checkRights()` is for — and shipping it only as an `@example` to copy went wrong
|
|
864
|
+
* twice in this framework's own history, both times permissively. So the four shapes are presets:
|
|
865
|
+
*
|
|
866
|
+
* | value | project class |
|
|
867
|
+
* |-------------------|------------------------------------------------------------------|
|
|
868
|
+
* | `'custom'` | you override `checkRights()` yourself — **the default**, the framework abstains |
|
|
869
|
+
* | `'public'` | open: anyone may read and write; the role gate is the whole policy |
|
|
870
|
+
* | `'authenticated'` | login-restricted: every signed-in user may use every file |
|
|
871
|
+
* | `'owner'` | per-user: only the uploader, plus ADMIN |
|
|
872
|
+
* | `'tenant'` | per-tenant: only within one's own validated tenant, plus ADMIN |
|
|
873
|
+
*
|
|
874
|
+
* `'owner'` and `'tenant'` read `metadata.ownerId` / `metadata.tenantId`, which the service STAMPS
|
|
875
|
+
* as it writes once one of them is active — so an upload through `CoreFileService` is authorizable
|
|
876
|
+
* without any project code. Files written BEFORE the preset was enabled carry no such metadata and
|
|
877
|
+
* are therefore ADMIN-only; that is the fail-closed direction, and a one-off backfill fixes it.
|
|
878
|
+
*
|
|
879
|
+
* Two things this setting never does: it never overrides an explicit `checkRights()` override (the
|
|
880
|
+
* override IS the rule), and it never widens the role gate. `'public'` still requires
|
|
881
|
+
* `downloadRoles: [S_EVERYONE]` — declaring the class and opening the gate are two decisions on
|
|
882
|
+
* purpose.
|
|
883
|
+
*
|
|
884
|
+
* An UNKNOWN value resolves to `'owner'`, not to `'custom'`: a typo means somebody believes they
|
|
885
|
+
* have an ownership rule, and confirming that belief is the one error that cannot be seen from
|
|
886
|
+
* outside.
|
|
887
|
+
*
|
|
888
|
+
* @default 'custom'
|
|
889
|
+
* @since 11.35.0
|
|
890
|
+
*/
|
|
891
|
+
access?: 'authenticated' | 'custom' | 'owner' | 'public' | 'tenant';
|
|
892
|
+
|
|
858
893
|
/**
|
|
859
894
|
* Roles allowed to DELETE files (`deleteFile` mutation).
|
|
860
895
|
*
|
|
@@ -996,9 +1031,28 @@ export interface IMultiTenancy {
|
|
|
996
1031
|
|
|
997
1032
|
/**
|
|
998
1033
|
* Model names (NOT collection names) to exclude from tenant filtering.
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1034
|
+
*
|
|
1035
|
+
* **This is an OFF SWITCH for data isolation, per model.** A listed schema gets no tenant filter at
|
|
1036
|
+
* all: every query on it sees every tenant's rows. That is correct for a genuinely global
|
|
1037
|
+
* collection, and it is a data leak for anything else.
|
|
1038
|
+
*
|
|
1039
|
+
* A schema is only affected if it HAS a `tenantId` field — the plugin attaches to no other. So
|
|
1040
|
+
* listing a model that declares `tenantId` overrides its author's intent, and since 11.35.0 the
|
|
1041
|
+
* plugin logs a warning naming the model when that happens. **Do not list a model just because a
|
|
1042
|
+
* `populate()` returns null** — that is the isolation working; scope the reference instead, or
|
|
1043
|
+
* confirm the referenced collection really is global.
|
|
1044
|
+
*
|
|
1045
|
+
* `User` is the one to think hardest about. It belongs here only when users are GLOBAL (one account
|
|
1046
|
+
* reaches several tenants, memberships carry the scope). If your users are per-tenant — they have a
|
|
1047
|
+
* `tenantId` — listing `User` switches isolation off for the account collection, which is usually
|
|
1048
|
+
* the last place you want it off. Earlier versions of this doc suggested `['User', 'Session']` as an
|
|
1049
|
+
* example; that suggestion is withdrawn, because it is only right for the global-user model.
|
|
1050
|
+
*
|
|
1051
|
+
* The membership model (`multiTenancy.membershipModel`, default `TenantMember`) is added
|
|
1052
|
+
* automatically and needs no entry — membership is tenant-spanning by design.
|
|
1053
|
+
*
|
|
1054
|
+
* @default []
|
|
1055
|
+
* @example [] // start here; add a model only when you can say why it is global
|
|
1002
1056
|
*/
|
|
1003
1057
|
excludeSchemas?: string[];
|
|
1004
1058
|
|
|
@@ -1039,23 +1093,86 @@ export interface IMultiTenancy {
|
|
|
1039
1093
|
* @default { member: 1, manager: 2, owner: 3 }
|
|
1040
1094
|
* @since 11.21.0
|
|
1041
1095
|
*
|
|
1096
|
+
* SECURITY: never name a tenant role after a GLOBAL role. `RoleEnum.ADMIN` (`'admin'`) and every
|
|
1097
|
+
* other member of `GLOBAL_ONLY_ROLES` is rejected here — a tenant role of that name used to
|
|
1098
|
+
* satisfy `@Roles(RoleEnum.ADMIN)` in tenant context, i.e. a customer could grant themselves
|
|
1099
|
+
* platform authority. Use a tenant-specific name (`tenantAdmin`, `spaceAdmin`) instead, and
|
|
1100
|
+
* declare your own platform-wide roles in {@link globalOnlyRoles}. The boot check refuses a
|
|
1101
|
+
* hierarchy that violates this.
|
|
1102
|
+
*
|
|
1042
1103
|
* @example
|
|
1043
1104
|
* ```typescript
|
|
1044
1105
|
* // config.env.ts
|
|
1045
1106
|
* multiTenancy: {
|
|
1046
|
-
* roleHierarchy: { viewer: 1, editor: 2, manager: 2,
|
|
1107
|
+
* roleHierarchy: { viewer: 1, editor: 2, manager: 2, tenantAdmin: 3, owner: 4 }
|
|
1047
1108
|
* }
|
|
1048
1109
|
*
|
|
1049
1110
|
* // roles.ts
|
|
1050
1111
|
* import { createHierarchyRoles } from '@lenne.tech/nest-server';
|
|
1051
|
-
* export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2,
|
|
1112
|
+
* export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2, tenantAdmin: 3, owner: 4 });
|
|
1052
1113
|
*
|
|
1053
1114
|
* // resolver.ts
|
|
1054
|
-
* @Roles(HR.EDITOR) // requires level >= 2 (editor, manager,
|
|
1115
|
+
* @Roles(HR.EDITOR) // requires level >= 2 (editor, manager, tenantAdmin, owner)
|
|
1055
1116
|
* ```
|
|
1056
1117
|
*/
|
|
1057
1118
|
roleHierarchy?: Record<string, number>;
|
|
1058
1119
|
|
|
1120
|
+
/**
|
|
1121
|
+
* Project-defined roles that carry GLOBAL (platform-wide) authority.
|
|
1122
|
+
*
|
|
1123
|
+
* `RoleEnum.ADMIN` is always treated this way; this option extends the set with your own.
|
|
1124
|
+
* A role listed here is ALWAYS resolved against `user.roles` and NEVER against a tenant
|
|
1125
|
+
* membership role — no matter what the `X-Tenant-Id` header says.
|
|
1126
|
+
*
|
|
1127
|
+
* **Why this matters.** Membership roles are customer-assigned free text, and in tenant context
|
|
1128
|
+
* required roles are otherwise compared against `membership.role`. Without this list, a global
|
|
1129
|
+
* endpoint guarded by `@Roles('auditor')` is reachable by any tenant member whose membership role
|
|
1130
|
+
* happens to be `'auditor'` — granted by their own tenant owner, not by you.
|
|
1131
|
+
*
|
|
1132
|
+
* Declaring a role here is therefore a security statement: "only the platform may grant this".
|
|
1133
|
+
* A role that appears BOTH here and in {@link roleHierarchy} fails the boot check, because it
|
|
1134
|
+
* would have to be resolved against two different sources at once.
|
|
1135
|
+
*
|
|
1136
|
+
* @default [] (only RoleEnum.ADMIN is global)
|
|
1137
|
+
* @since 11.35.0
|
|
1138
|
+
*
|
|
1139
|
+
* @example
|
|
1140
|
+
* ```typescript
|
|
1141
|
+
* multiTenancy: {
|
|
1142
|
+
* roleHierarchy: { member: 1, tenantAdmin: 2, owner: 3 }, // per tenant
|
|
1143
|
+
* globalOnlyRoles: ['auditor', 'support'], // platform-wide
|
|
1144
|
+
* }
|
|
1145
|
+
* ```
|
|
1146
|
+
*/
|
|
1147
|
+
globalOnlyRoles?: string[];
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Refuse membership roles that are not declared anywhere (deny by default).
|
|
1151
|
+
*
|
|
1152
|
+
* When `true`, `addMember()` / `updateMemberRole()` accept only roles present in
|
|
1153
|
+
* {@link roleHierarchy} or {@link additionalMembershipRoles}. When `false` (default, backward
|
|
1154
|
+
* compatible), any non-reserved string is accepted.
|
|
1155
|
+
*
|
|
1156
|
+
* An undeclared membership role can never GRANT anything either way — the guards only match
|
|
1157
|
+
* declared tenant roles — but with this on, the mistake surfaces as a 400 at assignment time
|
|
1158
|
+
* instead of as a membership that silently authorizes nothing.
|
|
1159
|
+
*
|
|
1160
|
+
* @default false
|
|
1161
|
+
* @since 11.35.0
|
|
1162
|
+
*/
|
|
1163
|
+
strictMembershipRoles?: boolean;
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Membership roles that are valid but carry no hierarchy level (exact-match roles).
|
|
1167
|
+
*
|
|
1168
|
+
* Only consulted when {@link strictMembershipRoles} is `true`. Use it for roles like `'auditor'`
|
|
1169
|
+
* that are meaningful per tenant but do not sit in the level ordering.
|
|
1170
|
+
*
|
|
1171
|
+
* @default []
|
|
1172
|
+
* @since 11.35.0
|
|
1173
|
+
*/
|
|
1174
|
+
additionalMembershipRoles?: string[];
|
|
1175
|
+
|
|
1059
1176
|
/**
|
|
1060
1177
|
* TTL in milliseconds for the tenant guard's in-memory membership cache.
|
|
1061
1178
|
* The cache avoids repeated DB lookups when the same user accesses the same tenant.
|
|
@@ -2210,7 +2327,8 @@ export interface IServerOptions {
|
|
|
2210
2327
|
*
|
|
2211
2328
|
* // Enable with excluded schemas and custom header
|
|
2212
2329
|
* multiTenancy: {
|
|
2213
|
-
* excludeSchemas
|
|
2330
|
+
* // excludeSchemas turns isolation OFF per model — see IMultiTenancy.excludeSchemas
|
|
2331
|
+
* excludeSchemas: [],
|
|
2214
2332
|
* headerName: 'x-tenant-id',
|
|
2215
2333
|
* },
|
|
2216
2334
|
* ```
|
|
@@ -2852,6 +2970,20 @@ export interface IS3Config {
|
|
|
2852
2970
|
presignedDownloads?:
|
|
2853
2971
|
| boolean
|
|
2854
2972
|
| {
|
|
2973
|
+
/**
|
|
2974
|
+
* Explicitly disable while keeping the settings around — the repo-wide
|
|
2975
|
+
* "presence implies enabled, unless `enabled: false`" pattern.
|
|
2976
|
+
*
|
|
2977
|
+
* It is spelled out in the type because it is REACHABLE WITHOUT THE TYPE: `NEST_SERVER_CONFIG`
|
|
2978
|
+
* and the `NSC__*` variables deliver plain JSON, so `{"enabled": false}` could always be set —
|
|
2979
|
+
* and `CoreS3Service` used to read any object as "enabled", which meant presigned downloads
|
|
2980
|
+
* came on while the boot warning (which does honour the key) stayed silent. Two code paths
|
|
2981
|
+
* answering one question differently is how a bearer-capability download gets switched on by
|
|
2982
|
+
* accident.
|
|
2983
|
+
* @default true (when the config object is present)
|
|
2984
|
+
*/
|
|
2985
|
+
enabled?: boolean;
|
|
2986
|
+
|
|
2855
2987
|
/**
|
|
2856
2988
|
* Presigned URL validity in seconds.
|
|
2857
2989
|
*
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { BadRequestException } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../enums/role.enum';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mongoose plugin that refuses to STORE system roles (`s_*`) in a `roles` array.
|
|
7
|
+
*
|
|
8
|
+
* **This answers a different question than `mongooseRoleGuardPlugin`, which is why it is a
|
|
9
|
+
* separate plugin:**
|
|
10
|
+
*
|
|
11
|
+
* | Plugin | Question | Configurable | On violation |
|
|
12
|
+
* |--------|----------|--------------|--------------|
|
|
13
|
+
* | `mongooseRoleGuardPlugin` | WHO may change roles? | yes (`security.mongooseRoleGuardPlugin`) | strips the change |
|
|
14
|
+
* | `mongooseSystemRolePlugin` | WHICH values may be stored at all? | **no** | throws |
|
|
15
|
+
*
|
|
16
|
+
* Mixing the two would be a mistake in both directions: an ADMIN is allowed to change roles but
|
|
17
|
+
* still must not be able to store `s_self`, and a bypass (`runWithBypassRoleGuard()` / `force:
|
|
18
|
+
* true`) grants authority over the *change*, never permission to write a value that is invalid by
|
|
19
|
+
* construction. So this check runs FIRST and runs ALWAYS — for admins, for bypassed writes, for
|
|
20
|
+
* system operations with no user context.
|
|
21
|
+
*
|
|
22
|
+
* **Why storing one is dangerous:** `hasRole()` is a plain string intersection
|
|
23
|
+
* (`core-user.model.ts`), so a stored `'s_self'` satisfies every `S_SELF` check — including
|
|
24
|
+
* `updateUser` / `deleteUser` on ARBITRARY users (mail/password change → account takeover) —
|
|
25
|
+
* without the account carrying any real role. The rule "NEVER store S_ roles in user.roles" was
|
|
26
|
+
* documented for years; this is where it became enforced.
|
|
27
|
+
*
|
|
28
|
+
* **Why it throws rather than stripping:** silently dropping the value would hide a
|
|
29
|
+
* misconfiguration in an admin tool, which is exactly how the dormant skeleton key gets minted in
|
|
30
|
+
* the first place. A loud 400 is recoverable; a silent success is not observable.
|
|
31
|
+
*
|
|
32
|
+
* **Why the check is broader than the six `RoleEnum.S_*` members:** it rejects ANY value that
|
|
33
|
+
* looks like a system role after trimming and lower-casing (see {@link looksLikeSystemRole}).
|
|
34
|
+
* The framework cannot distinguish a future system role from a project role that happens to start
|
|
35
|
+
* with `s_`, and a false rejection is fixable by renaming while a false acceptance is a silent
|
|
36
|
+
* authorization hole. Projects using an `s_`-prefixed role name must rename it — see the
|
|
37
|
+
* 11.34.x-to-11.35.x migration guide.
|
|
38
|
+
*
|
|
39
|
+
* Registered unconditionally in `CoreModule` — unlike the configurable plugins beside it, there is
|
|
40
|
+
* no opt-out, because there is no legitimate reason to store one of these values.
|
|
41
|
+
*/
|
|
42
|
+
export function mongooseSystemRolePlugin(schema) {
|
|
43
|
+
schema.pre('save', function () {
|
|
44
|
+
if (!this.isModified('roles')) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// A NEW document has no stored baseline, so every value in it is being introduced.
|
|
48
|
+
assertNoSystemRoles(this['roles'], this.isNew ? [] : toRoleArray(this.$__.originalRoles));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Capture the pre-modification value so the save hook above can tell an introduced value from a
|
|
52
|
+
// pre-existing one. `init` fires when a document is hydrated from the database.
|
|
53
|
+
schema.post('init', function () {
|
|
54
|
+
this.$__.originalRoles = Array.isArray(this['roles']) ? [...this['roles']] : undefined;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
for (const op of ['findOneAndUpdate', 'updateOne', 'replaceOne', 'findOneAndReplace']) {
|
|
58
|
+
schema.pre(op, async function () {
|
|
59
|
+
await assertUpdateIntroducesNoSystemRoles(this);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// updateMany can span documents with different stored values, so there is no single baseline to
|
|
64
|
+
// compare against. Any system role in the update payload is refused outright.
|
|
65
|
+
schema.pre('updateMany', function () {
|
|
66
|
+
assertUpdateHasNoSystemRoles(this.getUpdate());
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
schema.pre('insertMany', function (docs) {
|
|
70
|
+
if (!Array.isArray(docs)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const doc of docs) {
|
|
74
|
+
assertNoSystemRoles(doc?.roles);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
schema.pre('bulkWrite', function (ops) {
|
|
79
|
+
if (!Array.isArray(ops)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const op of ops) {
|
|
83
|
+
if ('insertOne' in op) {
|
|
84
|
+
assertNoSystemRoles(op.insertOne.document?.roles);
|
|
85
|
+
} else if ('updateOne' in op) {
|
|
86
|
+
assertUpdateHasNoSystemRoles(op.updateOne.update);
|
|
87
|
+
} else if ('updateMany' in op) {
|
|
88
|
+
assertUpdateHasNoSystemRoles(op.updateMany.update);
|
|
89
|
+
} else if ('replaceOne' in op) {
|
|
90
|
+
assertNoSystemRoles(op.replaceOne.replacement?.roles);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Normalize an unknown value to a string array; anything else becomes an empty baseline. */
|
|
97
|
+
function toRoleArray(value: unknown): string[] {
|
|
98
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Throw if the write INTRODUCES a system role.
|
|
103
|
+
*
|
|
104
|
+
* `alreadyStored` is the document's current value. A system role that is already in there is NOT
|
|
105
|
+
* refused, and this is load-bearing rather than lenient: `CrudService.update()` writes the whole
|
|
106
|
+
* object back, so a login (`updateRefreshToken` → `update`) re-sends the stored `roles` verbatim.
|
|
107
|
+
* Refusing that would lock every already-contaminated account out of the system on upgrade — and
|
|
108
|
+
* would do the same, fleet-wide, to any project whose own role name happens to start with `s_`.
|
|
109
|
+
* An upgrade must not cause a login outage; cleaning up stored values is what the migration guide's
|
|
110
|
+
* audit query is for.
|
|
111
|
+
*
|
|
112
|
+
* Accepts anything: a non-array (nothing to check) and non-string elements (cannot be a system
|
|
113
|
+
* role) pass through untouched — type validation is not this plugin's job.
|
|
114
|
+
*/
|
|
115
|
+
function assertNoSystemRoles(roles: unknown, alreadyStored: string[] = []): void {
|
|
116
|
+
if (!Array.isArray(roles)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const introduced = roles.filter((role) => looksLikeSystemRole(role) && !alreadyStored.includes(role));
|
|
121
|
+
if (introduced.length) {
|
|
122
|
+
throw new BadRequestException(
|
|
123
|
+
`System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles: ${introduced.join(', ')}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Query-level variant: resolves the affected document's stored roles before deciding.
|
|
130
|
+
*
|
|
131
|
+
* The lookup is skipped entirely unless the payload actually carries a system role, so the common
|
|
132
|
+
* case costs nothing — only a write that is about to be refused pays for one extra read.
|
|
133
|
+
*/
|
|
134
|
+
async function assertUpdateIntroducesNoSystemRoles(query: any): Promise<void> {
|
|
135
|
+
const update = query.getUpdate();
|
|
136
|
+
if (!update || !collectRoleValues(update).some((role) => looksLikeSystemRole(role))) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Only now — the payload is suspicious, so the baseline is worth one read.
|
|
141
|
+
let stored: string[] = [];
|
|
142
|
+
try {
|
|
143
|
+
const current = await query.model.findOne(query.getFilter()).select('roles').lean().exec();
|
|
144
|
+
stored = toRoleArray(current?.roles);
|
|
145
|
+
} catch {
|
|
146
|
+
// A failed lookup must not turn into an allow: fall through with an empty baseline, which
|
|
147
|
+
// refuses the write. Failing closed is correct for an authorization invariant.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
assertUpdateHasNoSystemRoles(update, stored);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every roles value the update could write, flattened across the operators that can carry one. */
|
|
154
|
+
function collectRoleValues(update: any): unknown[] {
|
|
155
|
+
const values: unknown[] = [
|
|
156
|
+
...toRoleArray(update.roles),
|
|
157
|
+
...toRoleArray(update.$set?.roles),
|
|
158
|
+
...toRoleArray(update.$setOnInsert?.roles),
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
for (const operator of [update.$push?.roles, update.$addToSet?.roles]) {
|
|
162
|
+
if (operator === undefined || operator === null) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
values.push(...(Array.isArray(operator?.$each) ? operator.$each : [operator]));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return values;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check every operator through which a roles value can reach the document.
|
|
173
|
+
*
|
|
174
|
+
* `$pull` is deliberately NOT checked — removing an `s_*` value from a document that already has
|
|
175
|
+
* one is exactly the cleanup this release asks projects to perform, so it must stay possible.
|
|
176
|
+
*/
|
|
177
|
+
function assertUpdateHasNoSystemRoles(update: any, alreadyStored: string[] = []): void {
|
|
178
|
+
if (!update) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
assertNoSystemRoles(update.roles, alreadyStored);
|
|
183
|
+
assertNoSystemRoles(update.$set?.roles, alreadyStored);
|
|
184
|
+
assertNoSystemRoles(update.$setOnInsert?.roles, alreadyStored);
|
|
185
|
+
|
|
186
|
+
// $push / $addToSet take either a bare value or a { $each: [...] } modifier
|
|
187
|
+
for (const operator of [update.$push?.roles, update.$addToSet?.roles]) {
|
|
188
|
+
if (operator === undefined || operator === null) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
assertNoSystemRoles(Array.isArray(operator?.$each) ? operator.$each : [operator], alreadyStored);
|
|
192
|
+
}
|
|
193
|
+
}
|