@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
|
@@ -125,7 +125,9 @@ Both invariants are enforced by `tests/unit/import-cycle-invariants.spec.ts`, wh
|
|
|
125
125
|
| `common/services` | `core-cron-jobs.registry.ts` (the cron infrastructure refs `CoreCronJobs` reads — `import type` only, so its emit is empty). Unlike the better-auth registry it IS barrel-exported: `setCronJobsInfrastructure()` / `getCronJobsInfrastructure()` are public, because a project may register its own connection or Redis service |
|
|
126
126
|
| `common/helpers` | `id.helper.ts` (ID cluster, out of `db.helper`) + `clone.helper.ts` (`clone`/`deepFreeze`, out of `input.helper`) |
|
|
127
127
|
| `common/inputs` | `FilterInput` + `CombinedFilterInput` merged into `filter.input.ts` — declaration order is load-bearing |
|
|
128
|
-
| `common/decorators` | `restricted.decorator` is on **zero** cycles; its exports are hoisted `function` declarations (TDZ-immune) as defense in depth |
|
|
128
|
+
| `common/decorators` | `restricted.decorator` is on **zero** cycles; its exports are hoisted `function` declarations (TDZ-immune) as defense in depth. `nested-type.registry.ts` (11.35.0) holds `nestedTypeRegistry` + `resolveNestedType`, because `unified-field.decorator` WRITES the map while `restricted.decorator` now READS it — and `unified-field` already imports `restricted` for `@Restricted`, so a direct read would put the access-control file back on a cycle. `unified-field.decorator` re-exports both, so no import path broke |
|
|
129
|
+
| `common/services` (tenant) | `core-tenant-context.registry.ts` (11.35.0) — how a transport without an Express request (the GraphQL WebSocket) reaches `CoreTenantGuard`'s membership logic. Under `common/` rather than next to the tenant module because the READER is `common/helpers/graphql-ws-context.helper`, and `src/core/common/**` must not import from `src/core/modules/**`; a registry rather than DI because the GraphQL wiring must not depend on a provider that only exists when multi-tenancy is configured |
|
|
130
|
+
| `modules/tus` | `tus.constants.ts` also holds `TUS_OWNER_METADATA_KEY` (11.35.0) |
|
|
129
131
|
|
|
130
132
|
Every old location re-exports what it lost, so no import path broke — the public API is byte-identical (472 exports, verified by diffing both versions).
|
|
131
133
|
|
|
@@ -265,15 +265,15 @@ This pattern is currently applied to:
|
|
|
265
265
|
| Response Model Interceptor | `security.responseModelInterceptor` | Boolean Shorthand | `true` (enabled), `debug: false` |
|
|
266
266
|
| Translate Response Interceptor | `security.translateResponseInterceptor` | Boolean Shorthand | `true` (enabled) |
|
|
267
267
|
| Secret Fields Removal | `security.secretFields` | Array | `['password', 'verificationToken', ...]` |
|
|
268
|
-
| Multi-Tenancy | `multiTenancy` | Presence Implies Enabled | `headerName: 'x-tenant-id'`, `membershipModel: 'TenantMember'`, `adminBypass: true`, `excludeSchemas: []`, `roleHierarchy: { member: 1, manager: 2, owner: 3 }`, `cacheTtlMs: 30000` (0 disables, process-local). System roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives before real roles; method-level system roles take precedence; membership validated for context when system role grants access + header present. Hierarchy roles use level comparison, normal roles use exact match. Use `DefaultHR` or `createHierarchyRoles()` for type-safe role constants. Bypass: `RequestContext.runWithBypassTenantGuard()`. Cache invalidation: `invalidateUser(userId)` / `invalidateAll()` are **instance** methods on the singleton `CoreTenantGuard` — inject it and call `this.tenantGuard?.invalidateUser(userId)`, never `CoreTenantGuard.invalidateUser(...)` (there is no static). With `redis` configured, both additionally BROADCAST the invalidation to every replica (`<keyPrefix>:tenant-cache:invalidate` pub/sub); without Redis they clear the local process only, so other replicas stay stale until `cacheTtlMs`. A received broadcast clears locally without re-publishing |
|
|
268
|
+
| Multi-Tenancy | `multiTenancy` | Presence Implies Enabled | **`excludeSchemas` is an OFF SWITCH for isolation, per model** — a listed schema gets no tenant filter at all; since 11.35.0 the plugin WARNS once per model when the excluded schema declares `tenantId` (i.e. was built for isolation). The framework's own docs recommended `['User', 'Session']` until then; that is withdrawn — it is only right for the global-user model. Since 11.35.0 GraphQL operations over the WEBSOCKET also run inside a `RequestContext` (`CoreModule` installs a context-aware `execute`/`subscribe` pair inside `subscriptions`, where `ApolloDriver` forwards it): the tenant comes from the handshake header validated against an active membership, else from the subscriber's memberships, and an unresolvable tenant leaves the safety net to refuse the read rather than returning everything. `headerName: 'x-tenant-id'`, `membershipModel: 'TenantMember'`, `adminBypass: true`, `excludeSchemas: []`, `roleHierarchy: { member: 1, manager: 2, owner: 3 }`, `cacheTtlMs: 30000` (0 disables, process-local). System roles (`S_EVERYONE`, `S_USER`, `S_VERIFIED`) are checked as OR alternatives before real roles; method-level system roles take precedence; membership validated for context when system role grants access + header present. Hierarchy roles use level comparison, normal roles use exact match. Use `DefaultHR` or `createHierarchyRoles()` for type-safe role constants. Bypass: `RequestContext.runWithBypassTenantGuard()`. Cache invalidation: `invalidateUser(userId)` / `invalidateAll()` are **instance** methods on the singleton `CoreTenantGuard` — inject it and call `this.tenantGuard?.invalidateUser(userId)`, never `CoreTenantGuard.invalidateUser(...)` (there is no static). With `redis` configured, both additionally BROADCAST the invalidation to every replica (`<keyPrefix>:tenant-cache:invalidate` pub/sub); without Redis they clear the local process only, so other replicas stay stale until `cacheTtlMs`. A received broadcast clears locally without re-publishing |
|
|
269
269
|
| BetterAuth Tenant Skip | `betterAuth.skipTenantCheck` | Explicit Boolean | `true` (default). When `true` and no `X-Tenant-Id` header is sent, IAM endpoints (controller + resolver) skip `CoreTenantGuard` tenant validation. When header IS present, normal membership validation runs regardless. Set `false` for tenant-aware auth scenarios (subdomain-based, invite links, SSO per tenant) |
|
|
270
270
|
| Debug Process Input | `debugProcessInput` | Explicit Boolean | `false` (default). When `true`, logs a debug message when `prepareInput()` changes the input type during `process()`. Has performance cost due to `JSON.stringify` on every `process()` call — enable only for debugging |
|
|
271
271
|
| JSONTransport Production Guard | `email.smtp` with `jsonTransport` | Runtime Guard | Throws `Error` when `email.smtp` has a truthy `jsonTransport` property in `production` or `staging` environments (read from config `env` field). JSONTransport silently discards all outgoing mail — the guard prevents accidental misconfiguration that causes password-reset, 2FA, and verification emails to vanish. Use `{ jsonTransport: true }` only in CI/e2e/local environments |
|
|
272
272
|
| Cookies | `cookies` | Boolean Shorthand (default true) | `true` (enabled), `exposeTokenInBody: false`. When enabled: loads `cookie-parser`, sets CORS `credentials: true`, sets signed httpOnly session cookies. When `exposeTokenInBody: true`: token stays in response body alongside cookies (for hybrid JWT+Cookie auth). JWT via `Authorization: Bearer` always works independently. **BetterAuth cookie name (since v11.27.6):** `createBetterAuthInstance()` pins `advanced.useSecureCookies: false` so BetterAuth's native handlers read the same UNPREFIXED `<cookiePrefix>.session_token` the helper writes (fixes a `401` split-brain on 2FA/passkey/`/token`); the `Secure` attribute is still applied on an `https://` baseURL via `advanced.defaultCookieAttributes`. Opt back into the `__Secure-` prefix with `betterAuth.options.advanced.useSecureCookies: true` only when BetterAuth manages cookies entirely |
|
|
273
273
|
| CORS | `cors` | Boolean Shorthand | `enabled: true`, `allowAll: false`, `deriveAppUrl: true`. Origins come from `appUrl`/`baseUrl`, resolved by the shared `resolveServerUrls()` helper (`cookies.helper.ts`) that ALL three CORS layers use (GraphQL, REST, BetterAuth `trustedOrigins`) — they can no longer drift. `appUrl` resolution: explicit → derived from a **host-split** localhost `baseUrl` (its `api.` label strips to a sibling host: `https://api.crm.localhost` → `https://crm.localhost`, as served by `lt dev up`; the port is preserved) → localhost default (`http://localhost:3001`, only for `env: local`/`ci`/`e2e` with a **port-split** localhost `baseUrl` — one host, API `:3000`, app `:3001`; `https://api.localhost` strips to the bare `localhost` the API already answers on and is therefore a port split, not a host split) → derived from `baseUrl` by stripping a leading `api.` label (`https://api.example.com` → `https://example.com`). **Security:** the derived origin receives credentialed CORS; set `deriveAppUrl: false` when the apex domain is not trusted, then list the frontend origin via `appUrl`/`allowedOrigins` (a host-split localhost `baseUrl` then falls back to the localhost default). The derivation never yields a bare TLD (`https://api.dev` unchanged) and never emits the opaque `null` origin (non-http(s) `baseUrl` passes through verbatim). `allowAll: true` mirrors the request origin for REST/GraphQL, but BetterAuth's `trustedOrigins` still resolve to `[appUrl]` (+ passkey origins) — an origin check has no "allow everything" mode, so a separately hosted frontend must appear in `appUrl`/`allowedOrigins` (or set `betterAuth.trustedOrigins` explicitly). `enabled: false` disables CORS on all layers including BetterAuth (`trustedOrigins: []`, which still trusts BetterAuth's own `baseURL`). Explicit `betterAuth.trustedOrigins` always takes precedence |
|
|
274
274
|
| Central Redis | `redis` | Boolean Shorthand + Presence Implies Enabled | `host: 'localhost'`, `port: 6379`, `db: 0`, `keyPrefix: <package.json name, slugified>` (per APPLICATION, not per framework — a constant default silently collides when two apps share one Redis; set it explicitly when sharing IS intended); `url` (takes precedence over host/port/db/credentials), `username`/`password`, `options` (passed to the ioredis constructor). `true` / `{}` enables with defaults; `{ enabled: false }` pre-configures without enabling; **absent = every consumer keeps its process-local fallback** (that fallback is the whole backward-compatibility story of 11.33.0). Requires the OPTIONAL peer `ioredis` — configured-but-missing **fails the boot** with a named error rather than crashing on first use. `keyPrefix` is applied by the framework per key, NOT as ioredis `keyPrefix` (that would collide with BullMQ's own prefix). One `CoreRedisService` serves all features: shared client (`getClient()`), one cached subscriber (`getSubscriber()` — a subscribing client cannot run commands), dedicated connections (`createClient(label)`); all are tracked and quit on shutdown. Switches on automatically: exact cross-replica rate limits via `RedisRateLimitStore` (see the Legacy Auth / BetterAuth / AI rate-limit rows), cron dedup (see Cron Job Deduplication row), `CoreRedisPubSub` as `PUB_SUB` for cluster-wide GraphQL subscriptions (**payloads must be JSON-serializable — `Date`, class instances, `Map`/`Set`, `undefined` do not survive**), tenant-cache invalidation broadcast, Hub collector mirroring, MCP session registry (turns a wrong-replica request from a misleading `404` into a `409` — `/ai/mcp` still REQUIRES sticky sessions, sessions are not portable) |
|
|
275
|
-
| File Access Roles | `file.downloadRoles`, `file.uploadRoles`, `file.deleteRoles` | Config-Driven Role List | `[RoleEnum.ADMIN]` each. **These are the only breaking change a single-replica project gets in 11.33.0** — six members moved from `@Roles(S_EVERYONE)` to these knobs: `GET /files/id/:id`, `GET /files/:filename` and `getFileInfo` (`downloadRoles`), `uploadFile` / `uploadFiles` (`uploadRoles`), `deleteFile` (`deleteRoles`). Plain role STRINGS, not `RoleEnum` members, so project roles work (`['company-admin', 'editor']`). Applied at boot by `applyFileRoles()` via `Reflect.defineMetadata('roles', …)` on the base-class methods — the same runtime mechanism `CorePermissionsModule` uses, because the value is only known from config. **`[]`, a non-array, or an array holding a non-string is REJECTED with a warning and the default applies** — an all-empty role set reads to the guards as "no roles required" and would OPEN the route, the exact opposite of the intent. **ADMIN is always unioned in**: both `CoreFileController` and `CoreFileResolver` carry a class-level `@Roles(RoleEnum.ADMIN)` and the guards UNION class + handler metadata, so these knobs can grant but never exclude admins. Both classes also carry `@SkipTenantCheck()` — GridFS and the S3 metadata collection are reached outside Mongoose, so `mongooseTenantPlugin` never scopes them and a role name alone cannot express a per-tenant rule; roles resolve against `user.roles`, never `membership.role`. **A subclass that OVERRIDES a member opts out permanently** (decorator metadata lives on the function object, and an override is a different function) — inherit the member instead. Roles are the coarse filter only; per-file rules belong in `CoreFileService.checkRights()`, which now receives `currentUser` and can read raw metadata via `getRawFileInfo()`. Implementation: `src/core/modules/file/file-roles.
|
|
276
|
-
| TUS Roles | `tus.roles` | Config-Driven Role List | `[RoleEnum.S_USER]` (was `S_EVERYONE` — breaking). A TUS upload writes into the SAME store the download routes guard, and the termination extension (on by default) can delete from it, so anonymous writes into a store only privileged callers may read is the wrong way round. Applied by `TusModule.applyRoles()` onto the registered controller class **and** onto `handleTus` / `handleTusWithId`. Same rejection rule as the file roles: `[]` / non-array / non-string → warning + `DEFAULT_TUS_CONFIG.roles`. **`OPTIONS` is deliberately exempt**: `handleTusOptions` / `handleTusOptionsWithId` keep their own handler-level `@Roles(RoleEnum.S_EVERYONE)`, because that is the CORS preflight — browsers send it WITHOUT credentials, and it returns server capabilities only. Gating it would make every browser upload fail before the first byte. `CoreTusController` also carries `@SkipTenantCheck()`. A custom controller is covered as long as it INHERITS the handlers; one that re-declares `@All()` / `@Roles()` carries its own metadata and thereby opts out — the documented way to hard-code a policy config must not be able to change. **Set `roles: [RoleEnum.S_EVERYONE]` explicitly if you accept attachments on a public form.** Implementation: `src/core/modules/tus/tus.module.ts`, `src/core/modules/tus/
|
|
275
|
+
| File Access Roles | `file.downloadRoles`, `file.uploadRoles`, `file.deleteRoles` | Config-Driven Role List | `[RoleEnum.ADMIN]` each. **These are the only breaking change a single-replica project gets in 11.33.0** — six members moved from `@Roles(S_EVERYONE)` to these knobs: `GET /files/id/:id`, `GET /files/:filename` and `getFileInfo` (`downloadRoles`), `uploadFile` / `uploadFiles` (`uploadRoles`), `deleteFile` (`deleteRoles`). Plain role STRINGS, not `RoleEnum` members, so project roles work (`['company-admin', 'editor']`). Applied at boot by `applyFileRoles()` via `Reflect.defineMetadata('roles', …)` on the base-class methods — the same runtime mechanism `CorePermissionsModule` uses, because the value is only known from config. **`[]`, a non-array, or an array holding a non-string is REJECTED with a warning and the default applies** — an all-empty role set reads to the guards as "no roles required" and would OPEN the route, the exact opposite of the intent. **ADMIN is always unioned in**: both `CoreFileController` and `CoreFileResolver` carry a class-level `@Roles(RoleEnum.ADMIN)` and the guards UNION class + handler metadata, so these knobs can grant but never exclude admins. Both classes also carry `@SkipTenantCheck()` — GridFS and the S3 metadata collection are reached outside Mongoose, so `mongooseTenantPlugin` never scopes them and a role name alone cannot express a per-tenant rule; roles resolve against `user.roles`, never `membership.role`. **A subclass that OVERRIDES a member opts out permanently** (decorator metadata lives on the function object, and an override is a different function) — inherit the member instead. Roles are the coarse filter only; per-file rules belong in `CoreFileService.checkRights()`, which now receives `currentUser` and can read raw metadata via `getRawFileInfo()`. **Since 11.35.0 a reused FILENAME resolves to the most recent file** in all three stores (`uploadDate` desc, `_id` tie-break) and every by-name read path resolves a document and then reads by ID — under GridFS the by-name metadata lookup answered the oldest document while `openDownloadStreamByName()` served the newest, so a per-file rule approved one file and the bytes of another came back. Consequence: `duplicateById()` keeps the source's filename and the copy carries no metadata, so the copy now wins the name and an `ownerId` rule refuses it — give the copy its own metadata or its own name. **Since 11.35.0 `CoreFileService` WARNS at construction** when `multiTenancy` is active AND a knob names a role other than ADMIN AND `checkRights()` is not overridden — the file stores are reached outside Mongoose, so these role names resolve against `user.roles` (global) and every holder reaches every tenant's files; only `checkRights()` can narrow that. Silent on the admin-only default and silent when a per-file rule exists. Implementation: `src/core/modules/file/file-roles.config.ts` (`FILE_ROLE_DEFAULTS`, the two warnings) + `file-roles.helper.ts` (`applyFileRoles`) |
|
|
276
|
+
| TUS Roles | `tus.roles` | Config-Driven Role List | `[RoleEnum.S_USER]` (was `S_EVERYONE` — breaking). A TUS upload writes into the SAME store the download routes guard, and the termination extension (on by default) can delete from it, so anonymous writes into a store only privileged callers may read is the wrong way round. Applied by `TusModule.applyRoles()` onto the registered controller class **and** onto `handleTus` / `handleTusWithId`. Same rejection rule as the file roles: `[]` / non-array / non-string → warning + `DEFAULT_TUS_CONFIG.roles`. **`OPTIONS` is deliberately exempt**: `handleTusOptions` / `handleTusOptionsWithId` keep their own handler-level `@Roles(RoleEnum.S_EVERYONE)`, because that is the CORS preflight — browsers send it WITHOUT credentials, and it returns server capabilities only. Gating it would make every browser upload fail before the first byte. `CoreTusController` also carries `@SkipTenantCheck()`. A custom controller is covered as long as it INHERITS the handlers; one that re-declares `@All()` / `@Roles()` carries its own metadata and thereby opts out — the documented way to hard-code a policy config must not be able to change. **Set `roles: [RoleEnum.S_EVERYONE]` explicitly if you accept attachments on a public form.** **Since 11.35.0 `tus.roles` is no longer the only gate**: `onUploadCreate` records the authenticated creator under `TUS_OWNER_METADATA_KEY` (`ltOwnerId`, always OVERWRITING any client-supplied value) and `onIncomingRequest` refuses HEAD/PATCH/DELETE naming an upload the caller does not own — with **404**, the file module's refusal policy. Before that, any authenticated caller who learned an upload id could APPEND BYTES to somebody else's upload, which are then migrated into the file store under the victim's filename. The finished file also gains `metadata.ownerId`, without which the documented per-file ownership rule could never authorize a tus-uploaded file (admin-only in practice). An OWNER-LESS upload stays reachable — uploads predating 11.35.0 have none, and neither does a deliberately public form. `readRequestUserId()` / `assertUploadOwnership()` are `protected`. Note that `@tus/server` v2 hands its hooks a WHATWG `ServerRequest`, so a guard's `req.user` is reachable only via `runtime.node.req`. Implementation: `src/core/modules/tus/tus.module.ts`, `src/core/modules/tus/core-tus.service.ts`, `src/core/modules/tus/tus.constants.ts` |
|
|
277
277
|
| File Storage | `file.storage` + `file.storageDir` + `s3` | Explicit Enum with DERIVED default | `'filesystem' \| 'gridfs' \| 's3'`. **Unset → derived**, most capable first: `'s3'` when `s3.bucket` is set, else `'gridfs'` when `mongoose.uri` is set, else `'filesystem'`. **Set → enforced**: an unavailable store FAILS THE BOOT (`assertFileStorageAvailable()`), it never falls back — a silent fallback puts files in a store the operator does not believe they are in, unrecoverably. A DERIVED driver is enforced too (`s3.bucket` set but `s3Service` not forwarded to `super()` → boot error). Metadata always lives in MongoDB whichever driver holds the bytes (`fs.files` / `s3-files` / `filesystem-files`) — it has to stay queryable for `findFileInfo()` and `checkRights()`. Reads consult ALL stores so switching drivers is forward-only with no migration; writes go to the active driver only. `'filesystem'` is pod-local: not shared between replicas, lost on restart unless `storageDir` is a mounted volume. `s3`: `bucket` (required — the one thing S3 cannot default, hence the eligibility test), `region: 'us-east-1'`, `forcePathStyle: false`, `stagingBucket: bucket`, `presignedDownloads: false` (`true`/`{}` → `expiresInSeconds: 300`), `endpoint` (MinIO/RustFS), `accessKeyId`/`secretAccessKey` (omit → AWS default credential chain), `enabled: false` to pre-configure. Requires the OPTIONAL peer `@aws-sdk/client-s3`, plus `@aws-sdk/s3-request-presigner` for presigned downloads. `presignedDownloads` makes `GET /files/id/:id` answer `302` to a time-limited S3 URL instead of streaming — the URL is a bearer capability, authorized once at issue time. The resolved driver is logged at boot (`[CoreFileStorage] File storage: …`) |
|
|
278
278
|
| TUS S3 Staging | `tus.s3Staging` | Explicit Boolean (default ON when S3 configured) | `true` when `s3` is configured, otherwise inert. Stages in-progress uploads in `s3.stagingBucket` via `@tus/s3-store` instead of `tus.uploadDir` on local disk, so resumable uploads survive replica restarts and need no sticky sessions. Set `false` to force local disk. Missing OPTIONAL peer `@tus/s3-store` → warning + fall back to local disk (NOT a boot failure). **Give the staging bucket a lifecycle rule expiring incomplete multipart uploads** — the framework's own expiration cleanup is skipped in S3 mode (S3 is the right place for that policy), so aborted uploads otherwise accumulate parts nothing removes |
|
|
279
279
|
| Cron Job Deduplication | per job: `distributed` in `CronJobConfig` | Explicit Boolean | **`true` when `redis` is configured, otherwise `false`** — a single-replica project that upgrades must not silently gain a `cron-locks` collection, a lease write per tick, and a new way for a tick to be skipped. A Redis-less multi-replica fleet opts in per job with `distributed: true` (MongoDB lease). Mechanism: BullMQ job scheduler when Redis + the OPTIONAL peer `bullmq` are present AND `cronTime` is a string without `utcOffset`; otherwise local timer + lease (Redis `SET NX`, else a TTL-indexed `cron-locks` document). Tick lease TTL 3600 s. **Leases fail open** — an unreachable lease store runs the tick everywhere rather than stopping all scheduled work fleet-wide. **`runOnInit` (default `true`) deduplicates over a FIXED per-job key with a 300 s TTL**, because replicas do not share a boot instant: replicas booting within 5 min run the startup tick once between them, and a replica restarting inside that window SKIPS its startup tick — set `distributed: false` on jobs whose `runOnInit` work is per-process (warming a process-local cache). No constructor change needed: `CoreModule` fills `core-cron-jobs.registry.ts` via `CoreCronJobsInitializer` and `CoreCronJobs` reads it lazily; explicit `{ connection, redisService }` in `CoreCronJobsOptions` wins. With neither source it warns once and every replica runs every tick |
|
|
@@ -21,6 +21,173 @@ System roles are used for **runtime checks only** and must **NEVER** be stored i
|
|
|
21
21
|
| `S_EVERYONE` | Public access | Always true |
|
|
22
22
|
| `S_NO_ONE` | Locked access | Always false |
|
|
23
23
|
|
|
24
|
+
### Since 11.35.0 this is ENFORCED, not just a convention
|
|
25
|
+
|
|
26
|
+
`hasRole()` is a plain string intersection, so a stored `'s_self'` satisfies **every** `S_SELF`
|
|
27
|
+
check — for the core user module that includes `updateUser` / `deleteUser` on *arbitrary* users
|
|
28
|
+
(mail/password change → account takeover) — while the account shows no privileged role at all.
|
|
29
|
+
The rule was documented for years and nothing enforced it. Three layers now do:
|
|
30
|
+
|
|
31
|
+
| Layer | Covers | On violation | Configurable |
|
|
32
|
+
|-------|--------|--------------|--------------|
|
|
33
|
+
| `CoreUserInput.roles` validator | the input/DTO path (REST + GraphQL, create + update) | 400 | no, but a subclass override **replaces** it (see below) |
|
|
34
|
+
| `CoreUserService.setRoles()` | the canonical role-assignment API (writes via `findByIdAndUpdate`) | throws, before the DB round-trip | no |
|
|
35
|
+
| `mongooseSystemRolePlugin` | **every** Mongoose write — incl. `force: true`, `runWithBypassRoleGuard()`, direct `Model` calls, seeds, migrations | throws | **no** |
|
|
36
|
+
|
|
37
|
+
Do not confuse the third with `mongooseRoleGuardPlugin`: that one answers *who may change roles*
|
|
38
|
+
(configurable, bypassable, **strips** the change); this one answers *which values may exist at all*
|
|
39
|
+
(unconditional, **throws**). Being ADMIN or holding a bypass is authority over the change, never
|
|
40
|
+
permission to write a value that is invalid by construction.
|
|
41
|
+
|
|
42
|
+
**The plugin refuses what a write INTRODUCES, not what is already stored** — load-bearing, not
|
|
43
|
+
leniency. `CrudService.update()` writes the whole object back, so a login
|
|
44
|
+
(`updateRefreshToken` → `update`) re-sends the stored `roles` verbatim; refusing that would lock
|
|
45
|
+
every already-contaminated account out on upgrade, and do the same fleet-wide to a project whose
|
|
46
|
+
own role name starts with `s_`. Adding a system role is refused, writing back an identical stored
|
|
47
|
+
value is allowed, and removing one always works. `updateMany` is the exception — it can span
|
|
48
|
+
documents with different baselines, so any system role in its payload is refused outright.
|
|
49
|
+
|
|
50
|
+
**The check is a prefix rule, not an allowlist of the six members above.** Any value starting with
|
|
51
|
+
`s_` after trimming, case-insensitively, is refused — including a project role you named
|
|
52
|
+
`s_manager`. The framework cannot tell those apart from a future system role, and a false
|
|
53
|
+
rejection is fixable by renaming while a false acceptance is a silent authorization hole.
|
|
54
|
+
|
|
55
|
+
Use the shared predicates rather than re-implementing the rule:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { isSystemRole, looksLikeSystemRole, SYSTEM_ROLE_PREFIX, SYSTEM_ROLE_REJECT_PATTERN }
|
|
59
|
+
from '@lenne.tech/nest-server';
|
|
60
|
+
|
|
61
|
+
isSystemRole('S_SELF') // false — exact + case-sensitive: the RUNTIME rule the guards use
|
|
62
|
+
looksLikeSystemRole('S_SELF') // true — trimmed + case-insensitive: the STORAGE rule
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
They differ on purpose. The guards compare role strings exactly, so `'S_SELF'` never granted
|
|
66
|
+
anything and must not be treated as a system role by them; the storage question deserves the
|
|
67
|
+
stricter answer, because `' s_self'` and `'S_SELF'` pass an eyeball review and become live the
|
|
68
|
+
moment anything normalizes roles.
|
|
69
|
+
|
|
70
|
+
**Upgrade note (11.35.0):** a write carrying an `s_*` role that previously succeeded now fails —
|
|
71
|
+
400 on the user endpoints, a thrown exception from `setRoles()` and from any Mongoose write. Audit
|
|
72
|
+
once with `db.users.find({ roles: /^\s*s_/i })` before upgrading; rename your own `s_`-prefixed
|
|
73
|
+
roles, and remove genuine misconfigurations. Also check every project input that **redeclares**
|
|
74
|
+
`roles`: `MapAndValidatePipe` walks the prototype chain child-first and skips a property once a
|
|
75
|
+
child class has validated it, so an override silently replaces the inherited validator (the plugin
|
|
76
|
+
still backstops the write). See `migration-guides/11.34.x-to-11.35.x.md` §2.
|
|
77
|
+
|
|
78
|
+
## The tenant boundary: membership roles never confer global authority (11.35.0)
|
|
79
|
+
|
|
80
|
+
Roles arrive from three sources with very different trust levels, and at runtime all three are
|
|
81
|
+
plain strings — `RoleEnum.ADMIN` **is** `'admin'`. TypeScript enums exist only at compile time, so
|
|
82
|
+
they provide no protection here. The danger is a shared namespace compared with `===`:
|
|
83
|
+
|
|
84
|
+
| Source | Example | Assigned by |
|
|
85
|
+
|--------|---------|-------------|
|
|
86
|
+
| Framework role | `admin` | the platform |
|
|
87
|
+
| System role | `s_self` | nobody — these are runtime questions, not roles |
|
|
88
|
+
| Tenant membership role | `tenantAdmin` | the **customer**, as free text (`addMember` takes any non-empty string) |
|
|
89
|
+
|
|
90
|
+
Two escalation paths existed until 11.35.0, both of them reachable by whoever may manage
|
|
91
|
+
members — typically a tenant owner, i.e. a customer:
|
|
92
|
+
|
|
93
|
+
- A membership role literally named `'admin'` satisfied `@Roles(RoleEnum.ADMIN)` in tenant context,
|
|
94
|
+
because the header path compared required roles against `membership.role` by exact string. That
|
|
95
|
+
is **platform** authority granted by a **customer**.
|
|
96
|
+
- A membership role literally named `'s_self'` satisfied field-level `@Restricted(S_SELF)` on
|
|
97
|
+
ARBITRARY records — ownership was never compared. `CoreTenantGuard` already filtered system roles
|
|
98
|
+
at its own call site; `checkRestricted` did not.
|
|
99
|
+
|
|
100
|
+
### The rule: the SOURCE decides, not the spelling
|
|
101
|
+
|
|
102
|
+
Each required role is resolved against the source that is entitled to answer it
|
|
103
|
+
(`resolveGlobalAndTenantRoles`, OR semantics across the two halves):
|
|
104
|
+
|
|
105
|
+
| Required | Resolved against |
|
|
106
|
+
|----------|------------------|
|
|
107
|
+
| `RoleEnum.ADMIN` (and every `GLOBAL_ONLY_ROLES` member) | `user.roles` — **never** `membership.role` |
|
|
108
|
+
| a tenant/project role (`tenantAdmin`, `auditor`, …) | `membership.role` in tenant context, `user.roles` otherwise |
|
|
109
|
+
| a system role (`S_SELF`, `S_CREATOR`, …) | its dedicated check (ownership, verification, session) — never a string comparison |
|
|
110
|
+
|
|
111
|
+
This is what makes the fix durable: it depends on no name list, so an already-stored membership
|
|
112
|
+
named `admin` is inert rather than dangerous — no data migration needed.
|
|
113
|
+
|
|
114
|
+
The intended model is unchanged and still works:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// Global admin — access to EVERY tenant
|
|
118
|
+
user.roles = [RoleEnum.ADMIN]
|
|
119
|
+
|
|
120
|
+
// Per-tenant admin — one tenant only; name it whatever the project likes, just not 'admin'
|
|
121
|
+
await tenantService.addMember(tenantId, userId, 'tenantAdmin');
|
|
122
|
+
@Roles('tenantAdmin')
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Second layer: reserved names are refused at assignment time
|
|
126
|
+
|
|
127
|
+
`addMember()` / `updateMemberRole()` throw on a system role (`s_*`) or a global-only role, matched
|
|
128
|
+
case-insensitively and trimmed (`isForbiddenMembershipRole`). This is **hygiene, not the
|
|
129
|
+
protection** — and the distinction matters: if a future `RoleEnum.SUPPORT` were added, every
|
|
130
|
+
pre-existing membership named `support` would become a hole retroactively, and no assignment-time
|
|
131
|
+
check can reach data that already exists. Only the source-based resolution above covers that.
|
|
132
|
+
|
|
133
|
+
### Adding a new framework role
|
|
134
|
+
|
|
135
|
+
`tests/unit/role-classification-invariants.spec.ts` fails unless every `RoleEnum` member is either
|
|
136
|
+
a system role (`s_` prefix) or listed in `GLOBAL_ONLY_ROLES`. Adding a role without making that
|
|
137
|
+
decision is exactly how the retroactive hole above gets created, so the test forces it at the
|
|
138
|
+
moment the role is declared.
|
|
139
|
+
|
|
140
|
+
### Declaring YOUR OWN global roles
|
|
141
|
+
|
|
142
|
+
The framework only knows its own vocabulary, so a project that guards a platform-wide endpoint with
|
|
143
|
+
`@Roles('auditor')` must say so — otherwise `auditor` is treated as an ordinary role and, in tenant
|
|
144
|
+
context, answered from `membership.role`:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
multiTenancy: {
|
|
148
|
+
roleHierarchy: { member: 1, tenantAdmin: 2, owner: 3 }, // per tenant
|
|
149
|
+
globalOnlyRoles: ['auditor', 'support'], // platform-wide, user.roles only
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The scope is stored as an **attribute**, not encoded in the role name. That is what keeps the design
|
|
154
|
+
open for admin-managed roles held in the database later: a `RoleScopeSource` reading from Mongo can
|
|
155
|
+
be registered alongside the config source, and every guard keeps working unchanged. Encoding the
|
|
156
|
+
scope in the string instead (`t:owner`) would put it in a value whoever creates the role can
|
|
157
|
+
mistype, and would add a normalization step inside the authorization path — the last place that
|
|
158
|
+
should grow moving parts.
|
|
159
|
+
|
|
160
|
+
| Registry answer | Meaning | Resolved against |
|
|
161
|
+
|-----------------|---------|------------------|
|
|
162
|
+
| `RoleScope.GLOBAL` | platform authority | `user.roles` |
|
|
163
|
+
| `RoleScope.TENANT` | authority within one tenant | `membership.role` |
|
|
164
|
+
| `RoleScope.SYSTEM` | runtime-context check | its dedicated check |
|
|
165
|
+
| `RoleScope.UNKNOWN` | declared nowhere | `user.roles`; **never** a membership role under `strictMembershipRoles` |
|
|
166
|
+
|
|
167
|
+
### Boot-time coherence check
|
|
168
|
+
|
|
169
|
+
`CoreTenantModule.forRoot()` calls `assertRoleVocabularyIsCoherent()` and **fails the boot** on:
|
|
170
|
+
|
|
171
|
+
1. a tenant role named after a framework role (`roleHierarchy: { admin: 3 }`),
|
|
172
|
+
2. a role declared in `globalOnlyRoles` *and* as a tenant role — it would need two sources of truth,
|
|
173
|
+
3. a system role declared in `globalOnlyRoles`.
|
|
174
|
+
|
|
175
|
+
Failing the boot is the intended severity: each of these describes a configuration whose access
|
|
176
|
+
decisions would be ambiguous, and an ambiguous authorization rule is worse than a server that
|
|
177
|
+
refuses to start.
|
|
178
|
+
|
|
179
|
+
> The framework's own documentation used to recommend
|
|
180
|
+
> `roleHierarchy: { viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 }` — i.e. exactly case 1.
|
|
181
|
+
> Projects that copied it had the escalation. The boot check now refuses that config.
|
|
182
|
+
|
|
183
|
+
### `strictMembershipRoles` (opt-in, deny by default)
|
|
184
|
+
|
|
185
|
+
With `multiTenancy.strictMembershipRoles: true`, only roles declared in `roleHierarchy` /
|
|
186
|
+
`additionalMembershipRoles` may be assigned **and** may satisfy a required role. Deliberately not
|
|
187
|
+
the default: a project may legitimately use exact-match roles that appear only in `@Roles()` and in
|
|
188
|
+
its membership data, and denying those at the guard by default would be a silent, fleet-wide
|
|
189
|
+
lockout. Turn it on for the strictest posture.
|
|
190
|
+
|
|
24
191
|
### `object` means the PERSISTED object — never the request payload
|
|
25
192
|
|
|
26
193
|
For `S_SELF` and `S_CREATOR`, "object" is the record loaded from the database
|
|
@@ -72,6 +239,120 @@ Now that the check reads the persisted object, they start working — and a fiel
|
|
|
72
239
|
owner-restricted may suddenly become writable by an admin who provisioned the record. **Audit every
|
|
73
240
|
`S_SELF`/`S_CREATOR` on an input type before upgrading.**
|
|
74
241
|
|
|
242
|
+
## `@Restricted` on NESTED data: only a DECLARED type is enforced (11.35.0)
|
|
243
|
+
|
|
244
|
+
`checkRestricted()` recurses, but until 11.35.0 it could not SEE anything one level down. The metadata
|
|
245
|
+
lookup reads from the value's class, and a nested value read out of MongoDB is a **plain object** —
|
|
246
|
+
`CoreModel.map()` is a shallow `Object.assign`, `prepareOutput()` maps only the target model, and
|
|
247
|
+
`ResponseModelInterceptor` only the top-level item. `Object` carries no `@Restricted` metadata, so
|
|
248
|
+
every nested restriction evaluated to "no restrictions at all". The same class WAS filtered when the
|
|
249
|
+
value happened to be an instance, which is why it went unnoticed for years.
|
|
250
|
+
|
|
251
|
+
Since 11.35.0 the nested-type registry that `@UnifiedField` already fills is read by
|
|
252
|
+
`checkRestricted()`, so a plain nested value is matched against the class its parent DECLARED — at
|
|
253
|
+
every level, and for every item of a declared array.
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
@ObjectType()
|
|
257
|
+
class Insurance {
|
|
258
|
+
@UnifiedField({ roles: RoleEnum.ADMIN, type: () => String })
|
|
259
|
+
policyNumber?: string; // enforced from 11.35.0, returned in full before
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@ObjectType()
|
|
263
|
+
class Patient extends CorePersistenceModel {
|
|
264
|
+
@UnifiedField({ type: () => Insurance }) // ← the `type` is what makes the nesting enforceable
|
|
265
|
+
insurance?: Insurance;
|
|
266
|
+
|
|
267
|
+
@UnifiedField({ isArray: true, type: () => Insurance })
|
|
268
|
+
insurances?: Insurance[]; // every item, not just the first
|
|
269
|
+
|
|
270
|
+
@UnifiedField({ isAny: true })
|
|
271
|
+
extra?: any; // NOT reached — nothing declares what this holds
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
| Situation | Enforced? |
|
|
276
|
+
|-----------|-----------|
|
|
277
|
+
| Top-level property | yes, always |
|
|
278
|
+
| Nested value that IS an instance of its class | yes, always |
|
|
279
|
+
| Nested plain object / array with `@UnifiedField({ type: () => X })` | **yes, since 11.35.0** |
|
|
280
|
+
| Nested value with no declared type (`isAny`, bare `@Field`) | **no** — see below |
|
|
281
|
+
| Class-level `@Restricted` on a nested type | contents stripped, empty container remains (the `checkObjectItself: false` default) |
|
|
282
|
+
|
|
283
|
+
### The opt-out flag is a MARKER now, not a property (11.35.0)
|
|
284
|
+
|
|
285
|
+
`_objectAlreadyCheckedForRestrictions` short-circuits `checkRestricted()`, `CheckSecurityInterceptor`
|
|
286
|
+
and `ResponseModelInterceptor`. It was recognised by a truthy test on the property name, and nothing in
|
|
287
|
+
`src/` ever wrote it — so a document carrying it (raw write, `strict: false` schema, restored export)
|
|
288
|
+
switched off every field-level check for that object, silently, on the output path.
|
|
289
|
+
|
|
290
|
+
The value must now be a module-private symbol, set through `markRestrictionsChecked(obj)`. The property
|
|
291
|
+
name is unchanged and the flag is non-enumerable, so it cannot reach a response body and re-enter as
|
|
292
|
+
data. A plain truthy value is no longer honoured — the object is CHECKED — and the framework warns once.
|
|
293
|
+
**Projects that set the flag by hand must switch to the helper.**
|
|
294
|
+
|
|
295
|
+
**An undeclared nested type stays unchecked on purpose.** Such a value is just as legitimately
|
|
296
|
+
free-form JSON, a `Map` or a scalar; failing closed there would strip vastly more than it protects. The
|
|
297
|
+
rule is therefore: *if a nested field must be protected, declare its type.*
|
|
298
|
+
|
|
299
|
+
**Upgrade note:** nested `@Restricted` fields start working. A response loses a nested restricted
|
|
300
|
+
field for callers who may not see it (correct), and an INPUT carrying one now throws 403 where it was
|
|
301
|
+
previously accepted. Audit nested `@Restricted` before upgrading.
|
|
302
|
+
|
|
303
|
+
## Tenant context on non-HTTP transports (11.35.0)
|
|
304
|
+
|
|
305
|
+
Every tenant decision the framework makes reads `RequestContext` (AsyncLocalStorage), which
|
|
306
|
+
`RequestContextMiddleware` installs — as **Express middleware**. A transport that does not traverse the
|
|
307
|
+
Express stack therefore had no context, and `mongooseTenantPlugin` reads "no context" as "system
|
|
308
|
+
operation, no filter". That is right for a cron job and wrong for a WebSocket.
|
|
309
|
+
|
|
310
|
+
| Transport | Context | Notes |
|
|
311
|
+
|-----------|---------|-------|
|
|
312
|
+
| HTTP (REST + GraphQL) | `RequestContextMiddleware` | `CoreTenantGuard` writes the validated tenant onto the request |
|
|
313
|
+
| GraphQL over WebSocket | **`execute`/`subscribe` wrappers, since 11.35.0** | `CoreModule` installs them on all three driver builders |
|
|
314
|
+
| Cron jobs, migrations, queue processors | none, by design | genuinely system-internal |
|
|
315
|
+
|
|
316
|
+
On the WebSocket path the tenant is resolved through `CoreTenantGuard.resolveTenantContext()` (reached
|
|
317
|
+
via `core-tenant-context.registry.ts`, because `src/core/common/**` must not import a provider that
|
|
318
|
+
only exists when multi-tenancy is configured): the handshake's tenant header is **validated against an
|
|
319
|
+
active membership**, and without a header the subscriber's memberships become `tenantIds`. A header
|
|
320
|
+
naming a tenant the subscriber does not belong to yields **no** tenant — the plugin's safety net then
|
|
321
|
+
refuses the read, which is strictly better than honouring an unvalidated header.
|
|
322
|
+
|
|
323
|
+
Two implementation details are load-bearing and easy to undo:
|
|
324
|
+
|
|
325
|
+
1. **The pair must sit inside `subscriptions`.** `ApolloDriver.start()` forwards only
|
|
326
|
+
`{ schema, path, context, ...options.subscriptions }` to `GqlSubscriptionService`; a top-level
|
|
327
|
+
`execute`/`subscribe` is dropped silently.
|
|
328
|
+
2. **The async iterator must be wrapped, not just the subscribe call.** graphql-js runs the per-event
|
|
329
|
+
execution (`resolve`, `filter`, field resolvers) inside the iterator's `next()`, which the transport
|
|
330
|
+
pulls long after `subscribe()` returned.
|
|
331
|
+
|
|
332
|
+
Both are pinned by `tests/unit/graphql-ws-context-wiring.spec.ts` (wiring) and
|
|
333
|
+
`tests/tenant-context-surfaces.e2e-spec.ts` (mechanism, over a real socket).
|
|
334
|
+
|
|
335
|
+
### The GUARDS were blind on the same transport, for the same reason (11.35.0)
|
|
336
|
+
|
|
337
|
+
A request context is only half of it. All three role guards resolved the request as
|
|
338
|
+
`ctx.getContext()?.req || switchToHttp().getRequest()` — and on a subscription the first half is absent
|
|
339
|
+
(the graphql-ws `extra` object has no `req`) while the second yields the resolver ROOT, i.e. `undefined`
|
|
340
|
+
at subscribe time. What they did with nothing pulled in opposite directions:
|
|
341
|
+
|
|
342
|
+
| Configuration | `@Roles()` on a `@Subscription` before 11.35.0 |
|
|
343
|
+
|---------------|-----------------------------------------------|
|
|
344
|
+
| `multiTenancy` active | `CoreTenantGuard` hit `if (!request) return true` and GRANTED. The role guard delegates non-system roles to it, so the role was checked by **nobody** |
|
|
345
|
+
| no `multiTenancy` | the role guard found no token and refused EVERYONE — the decorator silently meant "locked" |
|
|
346
|
+
|
|
347
|
+
`resolveGuardRequest()` (`src/core/common/helpers/execution-context-request.helper.ts`) is now the one
|
|
348
|
+
resolver all three use, and it recognises the subscription context. It requires the graphql-ws marker
|
|
349
|
+
(`socket` / `request` / `connectionParams`) rather than just "an object with a `user` field": a
|
|
350
|
+
subscription PAYLOAD plausibly has one (`pubSub.publish('userCreated', user)`), and accepting that
|
|
351
|
+
would make the PUBLISHED user the apparent caller.
|
|
352
|
+
|
|
353
|
+
**A guard must treat "no request" as no information, never as permission.** That conflation is the whole
|
|
354
|
+
defect, and it is the thing to look for when adding a fourth guard.
|
|
355
|
+
|
|
75
356
|
## Critical Rule
|
|
76
357
|
|
|
77
358
|
```typescript
|
package/CLAUDE.md
CHANGED
|
@@ -186,10 +186,20 @@ System roles (`S_` prefix) are runtime checks only - **NEVER store in user.roles
|
|
|
186
186
|
|
|
187
187
|
```typescript
|
|
188
188
|
@Roles(RoleEnum.S_USER) // Correct: runtime check
|
|
189
|
-
roles: [RoleEnum.S_USER] // WRONG:
|
|
189
|
+
roles: [RoleEnum.S_USER] // WRONG: rejected since 11.35.0 (400 / throws on every write path)
|
|
190
190
|
roles: [RoleEnum.ADMIN] // Correct: real role
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
Since 11.35.0 this is enforced on three layers (input validator, `setRoles()`,
|
|
194
|
+
`mongooseSystemRolePlugin` — the last one unconditional), and the rule is a **prefix** rule: any
|
|
195
|
+
value starting with `s_` is refused, including a project role named `s_manager`.
|
|
196
|
+
|
|
197
|
+
Since 11.35.0 a **membership role never confers global authority** (the source decides, not the
|
|
198
|
+
spelling), **`@Restricted` also applies to nested embedded subdocuments** whose type is declared via
|
|
199
|
+
`@UnifiedField({ type: () => X })`, and **GraphQL subscriptions run inside a `RequestContext`** — before
|
|
200
|
+
that a WebSocket operation had none, so `mongooseTenantPlugin` read it as "system operation, no filter"
|
|
201
|
+
and a tenant-scoped read on that path returned every tenant's rows.
|
|
202
|
+
|
|
193
203
|
See `.claude/rules/role-system.md` for complete documentation.
|
|
194
204
|
|
|
195
205
|
### Versioning
|
package/FRAMEWORK-API.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @lenne.tech/nest-server — Framework API Reference
|
|
2
2
|
|
|
3
|
-
> Auto-generated from source code on 2026-08-
|
|
3
|
+
> Auto-generated from source code on 2026-08-17 (v11.35.0)
|
|
4
4
|
> File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code
|
|
5
5
|
|
|
6
6
|
## CoreModule.forRoot()
|
|
@@ -70,11 +70,14 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
|
|
|
70
70
|
### IMultiTenancy
|
|
71
71
|
|
|
72
72
|
- `enabled?`: `boolean | undefined` (default: `true (when config object is present)`) — Explicitly disable multi-tenancy even when config is present.
|
|
73
|
-
- `excludeSchemas?`: `string[] | undefined` — Model names (NOT collection names) to exclude from tenant filtering.
|
|
73
|
+
- `excludeSchemas?`: `string[] | undefined` (default: `[]`) — Model names (NOT collection names) to exclude from tenant filtering.
|
|
74
74
|
- `headerName?`: `string | undefined` (default: `'x-tenant-id'`) — Header name for tenant selection.
|
|
75
75
|
- `membershipModel?`: `string | undefined` (default: `'TenantMember'`) — Mongoose model name for the membership collection.
|
|
76
76
|
- `adminBypass?`: `boolean | undefined` (default: `true`) — Whether system admins (RoleEnum.ADMIN) bypass the membership check.
|
|
77
77
|
- `roleHierarchy?`: `Record<string, number> | undefined` (default: `{ member: 1, manager: 2, owner: 3 }`) — Custom role hierarchy for tenant membership roles.
|
|
78
|
+
- `globalOnlyRoles?`: `string[] | undefined` (default: `[] (only RoleEnum.ADMIN is global)`) — Project-defined roles that carry GLOBAL (platform-wide) authority.
|
|
79
|
+
- `strictMembershipRoles?`: `boolean | undefined` (default: `false`) — Refuse membership roles that are not declared anywhere (deny by default).
|
|
80
|
+
- `additionalMembershipRoles?`: `string[] | undefined` (default: `[]`) — Membership roles that are valid but carry no hierarchy level (exact-match roles).
|
|
78
81
|
- `cacheTtlMs?`: `number | undefined` (default: `30000 (30 seconds)`) — TTL in milliseconds for the tenant guard's in-memory membership cache.
|
|
79
82
|
|
|
80
83
|
### IErrorCode
|
|
@@ -152,6 +155,7 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
|
|
|
152
155
|
|
|
153
156
|
### IFileConfig
|
|
154
157
|
|
|
158
|
+
- `access?`: `"authenticated" | "custom" | "owner" | "public" | "tenant" | undefined` (default: `'custom'`) — WHICH PROJECT CLASS this deployment is — the per-file rule, as a declaration instead of code.
|
|
155
159
|
- `deleteRoles?`: `string[] | undefined` (default: `['admin']`) — Roles allowed to DELETE files (`deleteFile` mutation).
|
|
156
160
|
- `downloadRoles?`: `string[] | undefined` (default: `['admin']`) — Roles allowed to DOWNLOAD files and read file info
|
|
157
161
|
- `storage?`: `"filesystem" | "gridfs" | "s3" | undefined` (default: `derived — see above`) — Storage driver for CoreFileService. Three equivalent options:
|
|
@@ -178,7 +182,7 @@ When `passkey` is enabled, `trustedOrigins` is required (compile-time enforcemen
|
|
|
178
182
|
- `enabled?`: `boolean | undefined` (default: `true (when config object is present)`) — Whether S3 is enabled.
|
|
179
183
|
- `endpoint?`: `string | undefined` (default: `undefined`) — Custom endpoint URL for S3-compatible services (MinIO, RustFS, ...).
|
|
180
184
|
- `forcePathStyle?`: `boolean | undefined` (default: `false`) — Use path-style addressing (required by most self-hosted S3 services).
|
|
181
|
-
- `presignedDownloads?`: `boolean | { expiresInSeconds?: number; } | undefined` (default: `false`) — Serve downloads as presigned URL redirects instead of streaming
|
|
185
|
+
- `presignedDownloads?`: `boolean | { enabled?: boolean; expiresInSeconds?: number; } | undefined` (default: `false`) — Serve downloads as presigned URL redirects instead of streaming
|
|
182
186
|
- `region?`: `string | undefined` (default: `'us-east-1'`) — AWS region
|
|
183
187
|
- `secretAccessKey?`: `string | undefined` (default: `undefined`) — Secret access key.
|
|
184
188
|
- `stagingBucket?`: `string | undefined` (default: `same as `bucket``) — Bucket used as staging area for resumable TUS uploads.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.nestedTypeRegistry = void 0;
|
|
4
|
+
exports.resolveNestedType = resolveNestedType;
|
|
5
|
+
exports.nestedTypeRegistry = new Map();
|
|
6
|
+
function resolveNestedType(ownerClass, propertyKey) {
|
|
7
|
+
let current = ownerClass;
|
|
8
|
+
while (current && current !== Object && typeof current === 'function') {
|
|
9
|
+
const found = exports.nestedTypeRegistry.get(`${current.name}.${propertyKey}`);
|
|
10
|
+
if (found) {
|
|
11
|
+
return found;
|
|
12
|
+
}
|
|
13
|
+
current = Object.getPrototypeOf(current);
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=nested-type.registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nested-type.registry.js","sourceRoot":"","sources":["../../../../src/core/common/decorators/nested-type.registry.ts"],"names":[],"mappings":";;;AA+BA,8CAUC;AAnBY,QAAA,kBAAkB,GAAG,IAAI,GAAG,EAAe,CAAC;AASzD,SAAgB,iBAAiB,CAAC,UAAmB,EAAE,WAAmB;IACxE,IAAI,OAAO,GAAQ,UAAU,CAAC;IAC9B,OAAO,OAAO,IAAI,OAAO,KAAK,MAAM,IAAI,OAAO,OAAO,KAAK,UAAU,EAAE,CAAC;QACtE,MAAM,KAAK,GAAG,0BAAkB,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,IAAI,IAAI,WAAW,EAAE,CAAC,CAAC;QACvE,IAAI,KAAK,EAAE,CAAC;YACV,OAAO,KAAK,CAAC;QACf,CAAC;QACD,OAAO,GAAG,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;IAC3C,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { ProcessType } from '../enums/process-type.enum';
|
|
3
3
|
import { RequireAtLeastOne } from '../types/required-at-least-one.type';
|
|
4
|
+
export { hasRestrictionsCheckedMarker, hasUnrecognizedRestrictionsFlag, markRestrictionsChecked, RESTRICTIONS_CHECKED_KEY, } from './restrictions-checked.marker';
|
|
4
5
|
export type RestrictedType = (RequireAtLeastOne<{
|
|
5
6
|
memberOf?: string | string[];
|
|
6
7
|
processType?: ProcessType;
|
|
@@ -28,4 +29,4 @@ export declare function checkRestricted(data: any, user: {
|
|
|
28
29
|
processType?: ProcessType;
|
|
29
30
|
removeUndefinedFromResultArray?: boolean;
|
|
30
31
|
throwError?: boolean;
|
|
31
|
-
}, processedObjects?: WeakSet<object
|
|
32
|
+
}, processedObjects?: WeakSet<object>, declaredType?: any): any;
|