@lenne.tech/nest-server 11.41.3 → 11.41.5

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.
Files changed (109) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/configurable-features.md +1 -0
  3. package/.claude/rules/role-system.md +15 -1
  4. package/.claude/rules/testing.md +16 -4
  5. package/CLAUDE.md +6 -3
  6. package/FRAMEWORK-API.md +4 -1
  7. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -1
  8. package/dist/core/common/helpers/logging.helper.js +2 -0
  9. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  10. package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -1
  11. package/dist/core/common/interfaces/server-options.interface.d.ts +21 -1
  12. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +2 -2
  13. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +46 -6
  14. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  15. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +7 -1
  16. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  17. package/dist/core/modules/api-token/core-api-token.constants.d.ts +6 -0
  18. package/dist/core/modules/api-token/core-api-token.constants.js +11 -0
  19. package/dist/core/modules/api-token/core-api-token.constants.js.map +1 -0
  20. package/dist/core/modules/api-token/core-api-token.decorators.d.ts +1 -0
  21. package/dist/core/modules/api-token/core-api-token.decorators.js +8 -0
  22. package/dist/core/modules/api-token/core-api-token.decorators.js.map +1 -0
  23. package/dist/core/modules/api-token/core-api-token.helpers.d.ts +127 -0
  24. package/dist/core/modules/api-token/core-api-token.helpers.js +398 -0
  25. package/dist/core/modules/api-token/core-api-token.helpers.js.map +1 -0
  26. package/dist/core/modules/api-token/core-api-token.middleware.d.ts +10 -0
  27. package/dist/core/modules/api-token/core-api-token.middleware.js +58 -0
  28. package/dist/core/modules/api-token/core-api-token.middleware.js.map +1 -0
  29. package/dist/core/modules/api-token/core-api-token.model.d.ts +20 -0
  30. package/dist/core/modules/api-token/core-api-token.model.js +199 -0
  31. package/dist/core/modules/api-token/core-api-token.model.js.map +1 -0
  32. package/dist/core/modules/api-token/core-api-token.module.d.ts +11 -0
  33. package/dist/core/modules/api-token/core-api-token.module.js +39 -0
  34. package/dist/core/modules/api-token/core-api-token.module.js.map +1 -0
  35. package/dist/core/modules/api-token/core-api-token.registry.d.ts +9 -0
  36. package/dist/core/modules/api-token/core-api-token.registry.js +17 -0
  37. package/dist/core/modules/api-token/core-api-token.registry.js.map +1 -0
  38. package/dist/core/modules/api-token/core-api-token.service.d.ts +104 -0
  39. package/dist/core/modules/api-token/core-api-token.service.js +550 -0
  40. package/dist/core/modules/api-token/core-api-token.service.js.map +1 -0
  41. package/dist/core/modules/auth/guards/roles.guard.js +17 -1
  42. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  43. package/dist/core/modules/better-auth/better-auth-roles.guard.js +12 -2
  44. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  45. package/dist/core/modules/better-auth/core-better-auth.middleware.js +4 -0
  46. package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
  47. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +4 -0
  48. package/dist/core/modules/better-auth/core-better-auth.module.js +18 -0
  49. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  50. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  51. package/dist/core/modules/migrate/migration-runner.js +3 -0
  52. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  53. package/dist/core/modules/tenant/core-tenant-guard.registry.d.ts +2 -0
  54. package/dist/core/modules/tenant/core-tenant-guard.registry.js +19 -0
  55. package/dist/core/modules/tenant/core-tenant-guard.registry.js.map +1 -0
  56. package/dist/core/modules/tenant/core-tenant.guard.d.ts +1 -0
  57. package/dist/core/modules/tenant/core-tenant.guard.js +28 -12
  58. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  59. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +1 -0
  60. package/dist/core/modules/tenant/core-tenant.helpers.js +19 -0
  61. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  62. package/dist/core/modules/tenant/core-tenant.module.d.ts +5 -2
  63. package/dist/core/modules/tenant/core-tenant.module.js +8 -0
  64. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  65. package/dist/core/modules/user/core-user.service.js +7 -0
  66. package/dist/core/modules/user/core-user.service.js.map +1 -1
  67. package/dist/core.module.js +6 -0
  68. package/dist/core.module.js.map +1 -1
  69. package/dist/index.d.ts +7 -0
  70. package/dist/index.js +7 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/tsconfig.build.tsbuildinfo +1 -1
  73. package/docs/REQUEST-LIFECYCLE.md +49 -1
  74. package/docs/security-overrides.md +21 -13
  75. package/migration-guides/11.41.3-to-11.41.4.md +172 -0
  76. package/migration-guides/11.41.4-to-11.41.5.md +144 -0
  77. package/package.json +35 -34
  78. package/src/core/common/helpers/graceful-shutdown.helper.ts +9 -0
  79. package/src/core/common/helpers/logging.helper.ts +7 -0
  80. package/src/core/common/helpers/process-diagnostics.helper.ts +4 -0
  81. package/src/core/common/interfaces/server-options.interface.ts +122 -6
  82. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +17 -10
  83. package/src/core/modules/ai/README.md +9 -1
  84. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +74 -7
  85. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +13 -1
  86. package/src/core/modules/api-token/INTEGRATION-CHECKLIST.md +121 -0
  87. package/src/core/modules/api-token/README.md +212 -0
  88. package/src/core/modules/api-token/core-api-token.constants.ts +27 -0
  89. package/src/core/modules/api-token/core-api-token.decorators.ts +29 -0
  90. package/src/core/modules/api-token/core-api-token.helpers.ts +711 -0
  91. package/src/core/modules/api-token/core-api-token.middleware.ts +57 -0
  92. package/src/core/modules/api-token/core-api-token.model.ts +193 -0
  93. package/src/core/modules/api-token/core-api-token.module.ts +48 -0
  94. package/src/core/modules/api-token/core-api-token.registry.ts +53 -0
  95. package/src/core/modules/api-token/core-api-token.service.ts +822 -0
  96. package/src/core/modules/auth/guards/roles.guard.ts +23 -2
  97. package/src/core/modules/better-auth/better-auth-roles.guard.ts +18 -4
  98. package/src/core/modules/better-auth/core-better-auth.middleware.ts +8 -0
  99. package/src/core/modules/better-auth/core-better-auth.module.ts +33 -0
  100. package/src/core/modules/migrate/README.md +15 -7
  101. package/src/core/modules/migrate/migration-runner.ts +9 -0
  102. package/src/core/modules/tenant/README.md +17 -0
  103. package/src/core/modules/tenant/core-tenant-guard.registry.ts +36 -0
  104. package/src/core/modules/tenant/core-tenant.guard.ts +52 -12
  105. package/src/core/modules/tenant/core-tenant.helpers.ts +30 -0
  106. package/src/core/modules/tenant/core-tenant.module.ts +19 -2
  107. package/src/core/modules/user/core-user.service.ts +14 -0
  108. package/src/core.module.ts +12 -0
  109. package/src/index.ts +12 -0
@@ -14,11 +14,13 @@ import { resolveGuardRequest } from '../../../common/helpers/execution-context-r
14
14
  import { firstValueFrom, isObservable } from 'rxjs';
15
15
 
16
16
  import { RoleEnum } from '../../../common/enums/role.enum';
17
+ import { ApiTokenKind } from '../../api-token/core-api-token.constants';
18
+ import { enforceApiTokenRoute } from '../../api-token/core-api-token.helpers';
17
19
  import { BetterAuthTokenService } from '../../better-auth/better-auth-token.service';
18
20
  import { BetterAuthenticatedUser } from '../../better-auth/better-auth.types';
19
21
  import { CoreBetterAuthService } from '../../better-auth/core-better-auth.service';
20
22
  import { ErrorCode } from '../../error-code';
21
- import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../../tenant/core-tenant.helpers';
23
+ import { delegatesRolesToTenantGuard, isSystemRole, mergeRolesMetadata } from '../../tenant/core-tenant.helpers';
22
24
  import { AuthGuardStrategy } from '../auth-guard-strategy.enum';
23
25
  import { ExpiredTokenException } from '../exceptions/expired-token.exception';
24
26
  import { InvalidTokenException } from '../exceptions/invalid-token.exception';
@@ -145,6 +147,25 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
145
147
  throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
146
148
  }
147
149
 
150
+ // API tokens are decided BEFORE the public-route shortcut below: a token is denied on every route
151
+ // that does not declare @ApiTokenScopes(), public ones included (see core-api-token.helpers).
152
+ // A tenant token is fully decided there. A user token was authenticated by CoreApiTokenMiddleware,
153
+ // so Passport — which only knows JWTs — must not see it: its user goes straight to the role checks.
154
+ const apiTokenKind = enforceApiTokenRoute({
155
+ controllerClass: context.getClass(),
156
+ handler: context.getHandler(),
157
+ request: this.getRequest(context),
158
+ });
159
+ if (apiTokenKind === ApiTokenKind.TENANT) {
160
+ return true;
161
+ }
162
+ if (apiTokenKind === ApiTokenKind.USER) {
163
+ if (roles.some((value) => !!value) && !roles.includes(RoleEnum.S_EVERYONE)) {
164
+ this.handleRequest(null, this.getRequest(context).user, null, context);
165
+ }
166
+ return true;
167
+ }
168
+
148
169
  // If no roles required, or S_EVERYONE is set, allow access without authentication
149
170
  // This allows public endpoints (without @Roles decorator or with S_EVERYONE) to work
150
171
  //
@@ -323,7 +344,7 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
323
344
  // When multiTenancy active: pass through ALL non-system roles to CoreTenantGuard.
324
345
  // CoreTenantGuard handles hierarchy (level) and non-hierarchy (exact) checks
325
346
  // against membership.role (tenant) or user.roles (no tenant).
326
- if (user && isMultiTenancyActive() && roles.some((r) => !isSystemRole(r))) {
347
+ if (user && delegatesRolesToTenantGuard() && roles.some((r) => !isSystemRole(r))) {
327
348
  return user;
328
349
  }
329
350
 
@@ -11,8 +11,10 @@ import { GqlExecutionContext } from '@nestjs/graphql';
11
11
  import { resolveGuardRequest } from '../../common/helpers/execution-context-request.helper';
12
12
 
13
13
  import { RoleEnum } from '../../common/enums/role.enum';
14
+ import { ApiTokenKind } from '../api-token/core-api-token.constants';
15
+ import { enforceApiTokenRoute } from '../api-token/core-api-token.helpers';
14
16
  import { ErrorCode } from '../error-code';
15
- import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../tenant/core-tenant.helpers';
17
+ import { delegatesRolesToTenantGuard, isSystemRole, mergeRolesMetadata } from '../tenant/core-tenant.helpers';
16
18
  import { BetterAuthTokenService } from './better-auth-token.service';
17
19
  import { BetterAuthenticatedUser } from './better-auth.types';
18
20
  import { getBetterAuthTokenService } from './core-better-auth.registry';
@@ -97,13 +99,25 @@ export class BetterAuthRolesGuard implements CanActivate {
97
99
  throw new ForbiddenException(ErrorCode.ACCESS_DENIED);
98
100
  }
99
101
 
102
+ // API tokens are decided BEFORE the public-route shortcut below: a token is denied on every route
103
+ // that does not declare @ApiTokenScopes(), public ones included. A tenant token is fully decided
104
+ // there; a user token continues through the ordinary checks as its user (see core-api-token.helpers).
105
+ const request = this.getRequest(context);
106
+ const apiTokenKind = enforceApiTokenRoute({
107
+ controllerClass: context.getClass(),
108
+ handler: context.getHandler(),
109
+ request,
110
+ });
111
+ if (apiTokenKind === ApiTokenKind.TENANT) {
112
+ return true;
113
+ }
114
+
100
115
  // If no roles required, or S_EVERYONE is set, allow access without authentication
101
116
  if (!roles || !roles.some((value) => !!value) || roles.includes(RoleEnum.S_EVERYONE)) {
102
117
  return true;
103
118
  }
104
119
 
105
- // Get request and check for user (set by BetterAuth middleware)
106
- const request = this.getRequest(context);
120
+ // Check for user (set by BetterAuth middleware or CoreApiTokenMiddleware)
107
121
  let user = request?.user;
108
122
 
109
123
  // If user isn't set (e.g., middleware didn't run in test environment),
@@ -129,7 +143,7 @@ export class BetterAuthRolesGuard implements CanActivate {
129
143
  // When multiTenancy active: pass through ALL non-system roles to CoreTenantGuard.
130
144
  // CoreTenantGuard handles hierarchy (level) and non-hierarchy (exact) checks
131
145
  // against membership.role (tenant) or user.roles (no tenant).
132
- if (isMultiTenancyActive() && roles.some((r) => !isSystemRole(r))) {
146
+ if (delegatesRolesToTenantGuard() && roles.some((r) => !isSystemRole(r))) {
133
147
  return true;
134
148
  }
135
149
 
@@ -1,6 +1,7 @@
1
1
  import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
2
2
  import { NextFunction, Request, Response } from 'express';
3
3
 
4
+ import { hasApiTokenCredential } from '../api-token/core-api-token.helpers';
4
5
  import { isLegacyJwt } from './core-better-auth-token.helper';
5
6
  import { BetterAuthSessionUser, CoreBetterAuthUserMapper, MappedUser } from './core-better-auth-user.mapper';
6
7
  import { convertExpressHeaders, extractSessionToken } from './core-better-auth-web.helper';
@@ -54,6 +55,13 @@ export class CoreBetterAuthMiddleware implements NestMiddleware {
54
55
  return next();
55
56
  }
56
57
 
58
+ // Leave API tokens to CoreApiTokenMiddleware (recognised by their configured prefix, `apiTokens`).
59
+ // Not even the cookie fallback may run: a request that presents a token is a token request, and a
60
+ // session cookie riding along must not turn it into a session request with the user's full rights.
61
+ if (hasApiTokenCredential(req)) {
62
+ return next();
63
+ }
64
+
57
65
  // Strategy 1: Try Authorization header (Bearer token) - takes precedence
58
66
  // The Authorization header is explicitly set by the client, so it should
59
67
  // override cookies which are implicitly sent by the browser.
@@ -34,6 +34,7 @@ import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.ser
34
34
  import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service';
35
35
  import { CoreBetterAuthRateLimitMiddleware } from './core-better-auth-rate-limit.middleware';
36
36
  import { CoreBetterAuthRateLimiter } from './core-better-auth-rate-limiter.service';
37
+ import { revokeApiTokensOfUser } from '../api-token/core-api-token.registry';
37
38
  import { getInFlightResetPassword } from './core-better-auth-password-reset.registry';
38
39
  import { CoreBetterAuthSignUpValidatorService } from './core-better-auth-signup-validator.service';
39
40
  import { CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
@@ -825,6 +826,33 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
825
826
  * They access the service via static reference since Better-Auth hooks run outside DI context.
826
827
  * @internal
827
828
  */
829
+ /**
830
+ * Revoke the user's API tokens after an IAM password reset — only when the reset also ends the
831
+ * user's sessions (`betterAuth.emailAndPassword.revokeSessionsOnPasswordReset`). A no-op without
832
+ * `apiTokens`. Never fails the reset: the credential is already written, so a failure is reported,
833
+ * not thrown. `protected` so a project can decide differently.
834
+ */
835
+ protected static async revokeApiTokensAfterPasswordReset(
836
+ user: { email?: string; id?: string } | undefined,
837
+ ): Promise<void> {
838
+ const revokeSessions =
839
+ this.configServiceInstance?.getFastButReadOnly('betterAuth.emailAndPassword.revokeSessionsOnPasswordReset') ===
840
+ true;
841
+ if (!revokeSessions || (!user?.email && !user?.id)) {
842
+ return;
843
+ }
844
+ try {
845
+ const revoked = await revokeApiTokensOfUser({ email: user.email, iamId: user.id });
846
+ if (revoked) {
847
+ this.logger.log(`Revoked ${revoked} API token(s) after the password reset of ${maskEmail(user.email ?? '')}`);
848
+ }
849
+ } catch (error) {
850
+ this.logger.error(
851
+ `Could not revoke the API tokens after a password reset: ${error instanceof Error ? error.message : String(error)}`,
852
+ );
853
+ }
854
+ }
855
+
828
856
  /**
829
857
  * `protected` rather than `private`: the callbacks it returns include `onPasswordReset`, which
830
858
  * decides what mirroring a reset into the legacy store means for a deployment. A project with a
@@ -860,6 +888,11 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
860
888
  }
861
889
  },
862
890
  onPasswordReset: async ({ user }) => {
891
+ // A reset that ends the user's sessions ends their API tokens too — a user token is a session
892
+ // in all but lifetime, and left alive it keeps the credential the reset was meant to retire
893
+ // working. Before the legacy mirror below, which returns early on IAM-only deployments.
894
+ await this.revokeApiTokensAfterPasswordReset(user);
895
+
863
896
  // Mirror the new password into the legacy bcrypt store, so a deployment running
864
897
  // Legacy Auth next to IAM does not keep the OLD password valid on the legacy path
865
898
  // after a reset — including a reset performed BECAUSE the old one leaked.
@@ -240,9 +240,12 @@ How the lock works, regardless of which entry point uses it:
240
240
 
241
241
  This ensures that in a cluster with multiple nodes, migrations run on only one machine at a time.
242
242
 
243
- **Active by default for `migrate up`.** Stores built by `createMigrationStore()` use the
244
- lock collection `migrations_lock` unless another name is given, and `MigrationRunner.up()`
245
- (the CLI's `up` command) acquires that lock around the whole run. This matters because the
243
+ **Active by default for `migrate up` and `migrate down`.** Stores built by `createMigrationStore()` use the
244
+ lock collection `migrations_lock` unless another name is given, and `MigrationRunner.up()` / `.down()`
245
+ (the CLI's `up` / `down` commands) acquire that lock around the whole run. `down` joined later (DEV-2728): a
246
+ rollback is typically run while a deploy is failing, i.e. while replicas restart and each boots into
247
+ `migrate up` — outside the lock the two rewrote the migration state concurrently. `migrate list` only
248
+ reads and stays unlocked. This matters because the
246
249
  container entrypoint runs migrations on **every** boot: without the lock, N replicas
247
250
  starting together each read the same empty state and apply the same pending migration N
248
251
  times. A replica that waited re-reads the state inside the lock and finds nothing pending.
@@ -530,10 +533,15 @@ Enable it via any of:
530
533
  | `NSC__MIGRATE__STRICT=1\|true\|yes` env var | CLI **and** programmatic runners (resolved in the `MigrationRunner` constructor) |
531
534
  | `new MigrationRunner({ strict: true, ... })` | programmatic |
532
535
 
533
- **Recommended for production images:** set `NSC__MIGRATE__STRICT=true` in the container
534
- environment. In an immutable image, a recorded-but-missing migration file can only mean a
535
- broken build (empty/miscopied `migrations/` directory) or a state-store mismatch (wrong
536
- database) — both are conditions where refusing to boot is correct.
536
+ **Off by default, on only when a project explicitly wants it.** Deleting migration files that
537
+ ran everywhere is a normal practice — a new instance never needs them, and git history restores
538
+ them if ever needed — and under strict mode every such deletion would refuse the boot. Turn it on
539
+ for images that must carry the FULL migration history. Where the history is complete by
540
+ design, a recorded-but-missing file can only mean a broken build (empty/miscopied
541
+ `migrations/` directory) or a state-store mismatch (wrong database), and refusing to boot is
542
+ correct. This is independent of the entrypoint's failure policy: a migration that RAN AND
543
+ FAILED aborts the container start by default (`MIGRATIONS_ALLOW_FAILURE=true` opts out per
544
+ deploy), whatever `strict` says.
537
545
 
538
546
  ### Programmatic usage (MigrationRunner)
539
547
 
@@ -297,8 +297,17 @@ export class MigrationRunner {
297
297
 
298
298
  /**
299
299
  * Rollback the last migration (down)
300
+ *
301
+ * Serialized through the same lock as `up()`. A rollback is typically run while a deploy
302
+ * is failing, i.e. exactly while replicas restart and each boots into `migrate up`; outside
303
+ * the lock the two would read and rewrite the migration state concurrently, and one of them
304
+ * would save a state that no longer matches the database.
300
305
  */
301
306
  async down(): Promise<void> {
307
+ await withMigrationLock(this.options.stateStore, () => this.runDown());
308
+ }
309
+
310
+ protected async runDown(): Promise<void> {
302
311
  const { _endMigration, _startMigration } = await import('./helpers/migration.helper');
303
312
 
304
313
  const state = await this.options.stateStore.loadAsync();
@@ -357,6 +357,23 @@ betterAuth: {
357
357
  When `skipTenantCheck: false`, IAM endpoints will require a valid `X-Tenant-Id` header
358
358
  and the user must be a member of that tenant for protected endpoints.
359
359
 
360
+ ## API Tokens
361
+
362
+ `apiTokens` (see `src/core/modules/api-token/README.md`) adds bearer tokens that respect every rule in
363
+ this module:
364
+
365
+ - A **TENANT token** belongs to one tenant, is managed by members holding `apiTokens.manageRole`, and
366
+ acts with the LOWEST role of `roleHierarchy` inside that tenant only — an `X-Tenant-Id` naming another
367
+ tenant is refused, and `@SkipTenantCheck()` does not unbind it. The boot refuses a hierarchy in which
368
+ that lowest role would reach the manage role.
369
+ - A **USER token** acts as its user through the ordinary membership checks of `CoreTenantGuard`, with
370
+ global roles removed (no admin bypass), optionally restricted to one tenant and capped by
371
+ `maxTenantRole`.
372
+
373
+ Both are denied on routes without `@ApiTokenScopes()`. **Call `CoreApiTokenService.deleteAllForTenant()`
374
+ when you delete a tenant — nothing does it for you**: the core has no tenant model to notice the
375
+ deletion, and a tenant token does not depend on any member, so it keeps authenticating until then.
376
+
360
377
  ## Related
361
378
 
362
379
  - [Integration Checklist](./INTEGRATION-CHECKLIST.md)
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Whether a tenant guard is actually REGISTERED in this process — as opposed to merely configured.
3
+ *
4
+ * `RolesGuard` and `BetterAuthRolesGuard` hand every non-system role over to the tenant guard when
5
+ * multi-tenancy is on, because that guard resolves them against the membership. Deciding that from
6
+ * CONFIGURATION alone meant: whenever configuration and registration were out of step, a role such
7
+ * as `RoleEnum.ADMIN` was handed to a guard that never ran, and every authenticated caller passed.
8
+ * The role guards therefore delegate only while this registry reports an active tenant guard.
9
+ *
10
+ * Counted, not flagged: `CoreTenantModule` registers (so a project's own guard class counts too), and
11
+ * so does `CoreTenantGuard` itself (for setups that list it as an APP_GUARD directly). Each disposer
12
+ * releases exactly its own registration.
13
+ *
14
+ * A true leaf: no imports (see `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)").
15
+ */
16
+
17
+ let activeTenantGuards = 0;
18
+
19
+ /**
20
+ * Record an active tenant guard. Returns a disposer that releases this registration exactly once.
21
+ */
22
+ export function registerActiveTenantGuard(): () => void {
23
+ activeTenantGuards++;
24
+ let released = false;
25
+ return () => {
26
+ if (!released) {
27
+ released = true;
28
+ activeTenantGuards = Math.max(0, activeTenantGuards - 1);
29
+ }
30
+ };
31
+ }
32
+
33
+ /** Is at least one tenant guard registered in this process? */
34
+ export function hasActiveTenantGuard(): boolean {
35
+ return activeTenantGuards > 0;
36
+ }
@@ -19,8 +19,16 @@ import { resolveGuardRequest } from '../../common/helpers/execution-context-requ
19
19
  import { ConfigService } from '../../common/services/config.service';
20
20
  import { ResolvedTenantContext, setTenantContextResolver } from '../../common/services/core-tenant-context.registry';
21
21
  import { CoreRedisService } from '../../common/services/core-redis.service';
22
+ import { ApiTokenKind } from '../api-token/core-api-token.constants';
23
+ import {
24
+ capApiTokenTenantRole,
25
+ enforceApiTokenRoute,
26
+ getApiTokenContext,
27
+ getApiTokenTenantRestriction,
28
+ } from '../api-token/core-api-token.helpers';
22
29
  import { ErrorCode } from '../error-code/error-codes';
23
30
  import { CoreTenantMemberModel } from './core-tenant-member.model';
31
+ import { registerActiveTenantGuard } from './core-tenant-guard.registry';
24
32
  import { SKIP_TENANT_CHECK_KEY } from './core-tenant.decorators';
25
33
  import { TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus } from './core-tenant.enums';
26
34
  import {
@@ -165,8 +173,14 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
165
173
  setTenantContextResolver({
166
174
  resolve: (user, headerTenantId) => this.resolveTenantContext(user, headerTenantId),
167
175
  });
176
+
177
+ // Lets the role guards delegate non-system roles to this guard — only while it exists.
178
+ this.releaseTenantGuard = registerActiveTenantGuard();
168
179
  }
169
180
 
181
+ /** Releases this guard's registration (see core-tenant-guard.registry.ts). */
182
+ private releaseTenantGuard?: () => void;
183
+
170
184
  /**
171
185
  * Answer "which tenant is this caller in?" WITHOUT an Express request.
172
186
  *
@@ -253,6 +267,8 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
253
267
  }
254
268
 
255
269
  onModuleDestroy(): void {
270
+ this.releaseTenantGuard?.();
271
+
256
272
  if (this.invalidationListener) {
257
273
  try {
258
274
  const subscriber = this.redisService?.getSubscriber();
@@ -324,11 +340,27 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
324
340
  return true;
325
341
  }
326
342
 
343
+ // API tokens (see core-api-token.helpers): a TENANT token is fully decided there — scopes, roles,
344
+ // binding to its own tenant. A USER token passed its scope check and continues below as its user,
345
+ // through the same membership validation as a session; only its restrictions are added on top.
346
+ const apiTokenKind = enforceApiTokenRoute({
347
+ controllerClass: context.getClass(),
348
+ handler: context.getHandler(),
349
+ request,
350
+ });
351
+ if (apiTokenKind === ApiTokenKind.TENANT) {
352
+ return true;
353
+ }
354
+ // A user token restricted to one tenant is bound to it like a header naming it — the helper has
355
+ // already refused a header naming any other tenant.
356
+ const apiTokenTenant = getApiTokenTenantRestriction(request.user);
357
+
327
358
  // Parse tenant header
328
359
  const headerName = (config.headerName ?? 'x-tenant-id').toLowerCase();
329
360
  const rawHeader = request.headers?.[headerName] as string | undefined;
330
361
  const headerTenantId =
331
- rawHeader && typeof rawHeader === 'string' && rawHeader.length <= 128 ? rawHeader.trim() : undefined;
362
+ apiTokenTenant ??
363
+ (rawHeader && typeof rawHeader === 'string' && rawHeader.length <= 128 ? rawHeader.trim() : undefined);
332
364
 
333
365
  // Two role sets for different purposes:
334
366
  //
@@ -367,7 +399,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
367
399
  const membership = await this.findMembershipCached(request.user.id, headerTenantId);
368
400
  if (membership) {
369
401
  request.tenantId = headerTenantId;
370
- request.tenantRole = membership.role as string;
402
+ request.tenantRole = capApiTokenTenantRole(request.user, membership.role as string) ?? undefined;
371
403
  }
372
404
  }
373
405
  return true;
@@ -380,10 +412,11 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
380
412
  // Read @SkipTenantCheck early — it suppresses tenant membership validation for system roles too.
381
413
  // When set, S_USER and S_VERIFIED still enforce authentication/verification, but no membership
382
414
  // check is performed even when a tenant header is present.
383
- const hasSkipDecorator = this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [
384
- context.getHandler(),
385
- context.getClass(),
386
- ]);
415
+ // A tenant-restricted user token stays bound to its tenant even on @SkipTenantCheck() routes:
416
+ // skipping the check would drop the binding, and the restriction is the whole point of the token.
417
+ const hasSkipDecorator =
418
+ !apiTokenTenant &&
419
+ this.reflector.getAllAndOverride<boolean>(SKIP_TENANT_CHECK_KEY, [context.getHandler(), context.getClass()]);
387
420
 
388
421
  // S_USER check — any authenticated user satisfies this system role.
389
422
  //
@@ -488,7 +521,9 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
488
521
  throw new ForbiddenException('Not a member of this tenant');
489
522
  }
490
523
 
491
- const memberRole = membership.role as string;
524
+ // A user token with maxTenantRole acts with the lower of its cap and the membership role; `null`
525
+ // (a role the hierarchy cannot compare with the cap) satisfies no tenant role at all.
526
+ const memberRole = capApiTokenTenantRole(user, membership.role as string);
492
527
 
493
528
  // Check role access if roles are required (hierarchy + normal, against membership.role).
494
529
  //
@@ -509,7 +544,8 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
509
544
  // The length guard is NOT redundant here (unlike the `.some()` above): checkRoleAccess
510
545
  // returns TRUE for an empty required-roles list, so calling it with no tenant roles would
511
546
  // grant access to a handler that only ever required a global role.
512
- const satisfiedByTenant = tenantRoles.length > 0 && checkRoleAccess(tenantRoles, undefined, memberRole);
547
+ const satisfiedByTenant =
548
+ tenantRoles.length > 0 && memberRole !== null && checkRoleAccess(tenantRoles, undefined, memberRole);
513
549
 
514
550
  if (!satisfiedGlobally && !satisfiedByTenant) {
515
551
  throw new ForbiddenException('Insufficient tenant role');
@@ -519,7 +555,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
519
555
  // Set validated tenant context on request (consumed by RequestContextMiddleware
520
556
  // lazy getter → context.tenantId / context.tenantRole, and by @CurrentTenant() via RequestContext)
521
557
  request.tenantId = headerTenantId;
522
- request.tenantRole = memberRole;
558
+ request.tenantRole = memberRole ?? undefined;
523
559
  return true;
524
560
  }
525
561
 
@@ -573,7 +609,10 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
573
609
  }
574
610
 
575
611
  const userId = request.user.id;
576
- const ttl = this.cacheTtlMs;
612
+ // A user token's maxTenantRole changes which memberships reach minLevel, so its answer is computed
613
+ // per request instead of shared through the per-user cache.
614
+ const capped = !!getApiTokenContext(request.user)?.maxTenantRole;
615
+ const ttl = capped ? 0 : this.cacheTtlMs;
577
616
 
578
617
  // When cache is enabled, check process-level cache
579
618
  if (ttl > 0) {
@@ -600,7 +639,8 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
600
639
  const hierarchy = getRoleHierarchy();
601
640
  ids = memberships
602
641
  .filter((m) => {
603
- const level = hierarchy[m.role as string] ?? 0;
642
+ const role = capApiTokenTenantRole(request.user, m.role as string);
643
+ const level = role === null ? 0 : (hierarchy[role] ?? 0);
604
644
  return level >= minLevel;
605
645
  })
606
646
  .map((m) => m.tenant as string);
@@ -808,7 +848,7 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
808
848
  throw new ForbiddenException('Not a member of this tenant');
809
849
  }
810
850
  request.tenantId = headerTenantId;
811
- request.tenantRole = membership.role as string;
851
+ request.tenantRole = capApiTokenTenantRole(user, membership.role as string) ?? undefined;
812
852
  return true;
813
853
  }
814
854
 
@@ -1,6 +1,9 @@
1
+ import { Logger } from '@nestjs/common';
2
+
1
3
  import { isForbiddenMembershipRole, isGlobalOnlyRole, looksLikeSystemRole } from '../../common/enums/role.enum';
2
4
  import { ConfigService } from '../../common/services/config.service';
3
5
  import { RoleScope, roleScopeRegistry, RoleScopeSource } from './core-role-scope.registry';
6
+ import { hasActiveTenantGuard } from './core-tenant-guard.registry';
4
7
  import { DEFAULT_ROLE_HIERARCHY } from './core-tenant.enums';
5
8
 
6
9
  /**
@@ -172,6 +175,33 @@ export function isMultiTenancyActive(): boolean {
172
175
  return !!config && config.enabled !== false;
173
176
  }
174
177
 
178
+ let warnedAboutMissingTenantGuard = false;
179
+
180
+ /**
181
+ * Should a role guard hand non-system roles over to the tenant guard?
182
+ *
183
+ * Only when multi-tenancy is on AND a tenant guard is actually registered. Configured-but-absent is
184
+ * not "someone else checks it" — it is "nobody checks it", so the role guard then resolves the roles
185
+ * itself against `user.roles` (fail-closed) and says so once.
186
+ */
187
+ export function delegatesRolesToTenantGuard(): boolean {
188
+ if (!isMultiTenancyActive()) {
189
+ return false;
190
+ }
191
+ if (hasActiveTenantGuard()) {
192
+ return true;
193
+ }
194
+ if (!warnedAboutMissingTenantGuard) {
195
+ warnedAboutMissingTenantGuard = true;
196
+ new Logger('CoreTenantModule').warn(
197
+ 'multiTenancy is configured but no tenant guard is registered — the role guards check non-system roles ' +
198
+ 'against user.roles themselves instead of delegating them. Register CoreTenantModule (CoreModule does so ' +
199
+ 'automatically when multiTenancy is set).',
200
+ );
201
+ }
202
+ return false;
203
+ }
204
+
175
205
  /**
176
206
  * Check if a role is a hierarchy role (present in the configured role hierarchy).
177
207
  * Returns false when multiTenancy is disabled to avoid false positives.
@@ -1,9 +1,10 @@
1
- import { CanActivate, DynamicModule, Global, Module, Type } from '@nestjs/common';
1
+ import { CanActivate, DynamicModule, Global, Module, OnModuleDestroy, OnModuleInit, Type } from '@nestjs/common';
2
2
  import { APP_GUARD } from '@nestjs/core';
3
3
  import { MongooseModule, SchemaFactory, getModelToken } from '@nestjs/mongoose';
4
4
  import { Model } from 'mongoose';
5
5
 
6
6
  import { roleScopeRegistry } from './core-role-scope.registry';
7
+ import { registerActiveTenantGuard } from './core-tenant-guard.registry';
7
8
  import { CoreTenantMemberModel } from './core-tenant-member.model';
8
9
  import { TENANT_MEMBER_MODEL_TOKEN } from './core-tenant.enums';
9
10
  import { assertRoleVocabularyIsCoherent, configRoleScopeSource } from './core-tenant.helpers';
@@ -58,7 +59,23 @@ export interface CoreTenantModuleOptions {
58
59
  */
59
60
  @Global()
60
61
  @Module({})
61
- export class CoreTenantModule {
62
+ export class CoreTenantModule implements OnModuleDestroy, OnModuleInit {
63
+ /** Releases this module's tenant-guard registration (see core-tenant-guard.registry.ts). */
64
+ private releaseTenantGuard?: () => void;
65
+
66
+ /**
67
+ * Tell the role guards that a tenant guard really runs, so they may hand non-system roles to it.
68
+ * Registered by the MODULE, not only by CoreTenantGuard, because `forRoot({ guard })` may register
69
+ * a project's own guard class instead.
70
+ */
71
+ onModuleInit(): void {
72
+ this.releaseTenantGuard = registerActiveTenantGuard();
73
+ }
74
+
75
+ onModuleDestroy(): void {
76
+ this.releaseTenantGuard?.();
77
+ }
78
+
62
79
  static forRoot(options: CoreTenantModuleOptions = {}): DynamicModule {
63
80
  // Teach the role-scope registry which roles are global and which are tenant-scoped, then
64
81
  // refuse to boot on a vocabulary that cannot be enforced coherently (a tenant role named after
@@ -13,6 +13,7 @@ import { ConfigService } from '../../common/services/config.service';
13
13
  import { CrudService } from '../../common/services/crud.service';
14
14
  import { ErrorCode } from '../error-code/error-codes';
15
15
  import { EmailService } from '../../common/services/email.service';
16
+ import { revokeApiTokensOfUser } from '../api-token/core-api-token.registry';
16
17
  import { CoreModelConstructor } from '../../common/types/core-model-constructor.type';
17
18
  import { CoreUserModel } from './core-user.model';
18
19
  import { CoreUserCreateInput } from './inputs/core-user-create.input';
@@ -362,6 +363,19 @@ export abstract class CoreUserService<
362
363
  }
363
364
  }
364
365
 
366
+ // The legacy reset ends every legacy session above (refreshTokens), so it ends the user's
367
+ // API tokens too — left alive, they keep the credential the reset was meant to retire
368
+ // working. A no-op without `apiTokens`; never fails the reset.
369
+ try {
370
+ await revokeApiTokensOfUser({ email: dbObject.email, userId: dbObject.id });
371
+ } catch (error) {
372
+ this.userServiceLogger.error(
373
+ `Could not revoke the API tokens after the password reset of ${maskEmail(dbObject.email)}: ${
374
+ error instanceof Error ? error.message : 'Unknown error'
375
+ }`,
376
+ );
377
+ }
378
+
365
379
  return updatedUser;
366
380
  },
367
381
  { dbObject, serviceOptions },
@@ -59,6 +59,7 @@ import { CoreHubModule } from './core/modules/hub/core-hub.module';
59
59
  import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config.helper';
60
60
  import { CorePermissionsModule } from './core/modules/permissions/core-permissions.module';
61
61
  import { CoreSystemSetupModule } from './core/modules/system-setup/core-system-setup.module';
62
+ import { CoreApiTokenModule } from './core/modules/api-token/core-api-token.module';
62
63
  import { CoreTenantModule } from './core/modules/tenant/core-tenant.module';
63
64
 
64
65
  /**
@@ -579,6 +580,17 @@ export class CoreModule implements NestModule {
579
580
  imports.push(CoreTenantModule.forRoot({ modelName: membershipModelName }));
580
581
  }
581
582
 
583
+ // Add CoreApiTokenModule when apiTokens is configured (boolean shorthand, presence implies enabled).
584
+ // Independent of multiTenancy: user tokens work without it; tenant tokens and tenant restrictions
585
+ // switch on by themselves when CoreTenantModule is registered as well.
586
+ const apiTokensConfig = config.apiTokens;
587
+ if (
588
+ apiTokensConfig === true ||
589
+ (typeof apiTokensConfig === 'object' && apiTokensConfig !== null && apiTokensConfig.enabled !== false)
590
+ ) {
591
+ imports.push(CoreApiTokenModule.forRoot({ ...overrides?.apiToken }));
592
+ }
593
+
582
594
  // Set exports
583
595
  const exports: any[] = [
584
596
  ConfigService,
package/src/index.ts CHANGED
@@ -125,6 +125,18 @@ export * from './core/common/types/required-at-least-one.type';
125
125
  export * from './core/common/types/string-or-object-id.type';
126
126
  export * from './core/common/types/wrapper.type';
127
127
 
128
+ // =====================================================================================================================
129
+ // Core - Modules - API tokens
130
+ // =====================================================================================================================
131
+
132
+ export * from './core/modules/api-token/core-api-token.constants';
133
+ export * from './core/modules/api-token/core-api-token.decorators';
134
+ export * from './core/modules/api-token/core-api-token.helpers';
135
+ export * from './core/modules/api-token/core-api-token.middleware';
136
+ export * from './core/modules/api-token/core-api-token.model';
137
+ export * from './core/modules/api-token/core-api-token.module';
138
+ export * from './core/modules/api-token/core-api-token.service';
139
+
128
140
  // =====================================================================================================================
129
141
  // Core - Modules - Auth
130
142
  // =====================================================================================================================