@astram/guard 0.7.0 → 0.8.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.
Files changed (184) hide show
  1. package/dist/auth/account.controller.d.ts +3 -1
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +12 -5
  4. package/dist/auth/auth.controller.d.ts +130 -21
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +537 -143
  7. package/dist/auth/auth.module.d.ts +15 -1
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +212 -30
  10. package/dist/auth/connections.d.ts +1 -1
  11. package/dist/auth/connections.d.ts.map +1 -1
  12. package/dist/auth/connections.js +14 -3
  13. package/dist/auth/default-session-facts-projector.d.ts +26 -0
  14. package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
  15. package/dist/auth/default-session-facts-projector.js +21 -0
  16. package/dist/auth/mfa-step-up.d.ts +32 -0
  17. package/dist/auth/mfa-step-up.d.ts.map +1 -0
  18. package/dist/auth/mfa-step-up.js +109 -0
  19. package/dist/auth/pending-mfa-store.d.ts +50 -52
  20. package/dist/auth/pending-mfa-store.d.ts.map +1 -1
  21. package/dist/auth/pending-mfa-store.js +103 -61
  22. package/dist/auth/ports/session-facts-projector.d.ts +5 -0
  23. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
  24. package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
  25. package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
  26. package/dist/auth/rate-limit/canonical-hint.js +63 -0
  27. package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
  28. package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
  29. package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
  30. package/dist/auth/tokens.d.ts +70 -11
  31. package/dist/auth/tokens.d.ts.map +1 -1
  32. package/dist/auth/tokens.js +71 -12
  33. package/dist/authentication/log-email-sender.d.ts +9 -4
  34. package/dist/authentication/log-email-sender.d.ts.map +1 -1
  35. package/dist/authentication/mariadb-api-key-store.d.ts +4 -1
  36. package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
  37. package/dist/authentication/mariadb-api-key-store.js +31 -7
  38. package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
  39. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  40. package/dist/authentication/mariadb-credential-resolver.js +116 -73
  41. package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
  42. package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
  43. package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
  44. package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
  45. package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
  46. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  47. package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
  48. package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
  49. package/dist/authentication/mariadb-session-sweeper.js +29 -11
  50. package/dist/authentication/noop-email-sender.d.ts +12 -0
  51. package/dist/authentication/noop-email-sender.d.ts.map +1 -0
  52. package/dist/authentication/noop-email-sender.js +9 -0
  53. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
  54. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
  55. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
  56. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
  57. package/dist/authz/mariadb-policy-store.js +1 -1
  58. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
  59. package/dist/authz/mariadb-record-rule-adapter.js +1 -4
  60. package/dist/authz/permission-version-cache.d.ts +38 -0
  61. package/dist/authz/permission-version-cache.d.ts.map +1 -0
  62. package/dist/authz/permission-version-cache.js +81 -0
  63. package/dist/authz/wire-record-authorization.d.ts +25 -1
  64. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  65. package/dist/authz/wire-record-authorization.js +55 -2
  66. package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
  67. package/dist/config/app-config.d.ts +36 -0
  68. package/dist/config/app-config.d.ts.map +1 -1
  69. package/dist/config/app-config.js +44 -4
  70. package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
  71. package/dist/enforcement/enforcement.module.d.ts.map +1 -1
  72. package/dist/enforcement/enforcement.module.js +9 -0
  73. package/dist/enforcement/engine-error.filter.d.ts +57 -0
  74. package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
  75. package/dist/enforcement/engine-error.filter.js +118 -0
  76. package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
  77. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  78. package/dist/enforcement/global-enforcement.guard.js +143 -5
  79. package/dist/identity/auth-facts.controller.d.ts.map +1 -1
  80. package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
  81. package/dist/identity/permission-facts.adapter.js +2 -3
  82. package/dist/index.d.ts +12 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +65 -25
  85. package/dist/module/shield.module.d.ts +9 -2
  86. package/dist/module/shield.module.d.ts.map +1 -1
  87. package/dist/module/shield.module.js +47 -38
  88. package/dist/request-auth/session-facts-als.d.ts +16 -1
  89. package/dist/request-auth/session-facts-als.d.ts.map +1 -1
  90. package/dist/request-auth/session-facts-als.js +23 -0
  91. package/dist/request-auth/session-token-binder.d.ts +166 -10
  92. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  93. package/dist/request-auth/session-token-binder.js +408 -28
  94. package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
  95. package/dist/secrets/encrypted-file-secrets.js +9 -3
  96. package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
  97. package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
  98. package/dist/session/hot-revoking-session-record-store.js +58 -0
  99. package/dist/session/index.d.ts +3 -1
  100. package/dist/session/index.d.ts.map +1 -1
  101. package/dist/session/index.js +8 -1
  102. package/dist/session/mariadb-session-record-store.d.ts +6 -0
  103. package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
  104. package/dist/session/mariadb-session-record-store.js +16 -1
  105. package/dist/session/session-lifetimes.d.ts +30 -0
  106. package/dist/session/session-lifetimes.d.ts.map +1 -0
  107. package/dist/session/session-lifetimes.js +21 -0
  108. package/dist/session/session-restriction-store.d.ts +43 -0
  109. package/dist/session/session-restriction-store.d.ts.map +1 -0
  110. package/dist/session/session-restriction-store.js +35 -0
  111. package/package.json +3 -3
  112. package/dist/auth/account.controller.js.map +0 -1
  113. package/dist/auth/auth.controller.js.map +0 -1
  114. package/dist/auth/auth.module.js.map +0 -1
  115. package/dist/auth/connections.js.map +0 -1
  116. package/dist/auth/dto.js.map +0 -1
  117. package/dist/auth/pending-mfa-store.js.map +0 -1
  118. package/dist/auth/persistence.module.js.map +0 -1
  119. package/dist/auth/ports/audit-sink.js.map +0 -1
  120. package/dist/auth/ports/credential-writer.js.map +0 -1
  121. package/dist/auth/ports/email-sender.js.map +0 -1
  122. package/dist/auth/ports/membership-checker.js.map +0 -1
  123. package/dist/auth/ports/mfa-device-store.js.map +0 -1
  124. package/dist/auth/ports/profile-writer.js.map +0 -1
  125. package/dist/auth/ports/recovery-code-store.js.map +0 -1
  126. package/dist/auth/ports/reset-token-store.js.map +0 -1
  127. package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
  128. package/dist/auth/ports/session-facts-projector.js.map +0 -1
  129. package/dist/auth/ports/session-sweeper.js.map +0 -1
  130. package/dist/auth/profile-resolver.js.map +0 -1
  131. package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
  132. package/dist/auth/tokens.js.map +0 -1
  133. package/dist/auth/totp-enrollment.js.map +0 -1
  134. package/dist/authentication/argon2id-password-hasher.js.map +0 -1
  135. package/dist/authentication/log-email-sender.js.map +0 -1
  136. package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
  137. package/dist/authentication/mariadb-credential-writer.js.map +0 -1
  138. package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
  139. package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
  140. package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
  141. package/dist/authentication/mariadb-profile-writer.js.map +0 -1
  142. package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
  143. package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
  144. package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
  145. package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
  146. package/dist/authz/list-scope-sql.js.map +0 -1
  147. package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
  148. package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
  149. package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
  150. package/dist/authz/mariadb-policy-store.js.map +0 -1
  151. package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
  152. package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
  153. package/dist/authz/record-share-cascade.js.map +0 -1
  154. package/dist/authz/record-share-writer.js.map +0 -1
  155. package/dist/authz/wire-record-authorization.js.map +0 -1
  156. package/dist/boot/boot-sequence.js.map +0 -1
  157. package/dist/boot/boot.module.js.map +0 -1
  158. package/dist/completeness/catalog-reconciler.js.map +0 -1
  159. package/dist/completeness/operation-inventory-scanner.js.map +0 -1
  160. package/dist/completeness/ports/catalog-store.js.map +0 -1
  161. package/dist/config/app-config.js.map +0 -1
  162. package/dist/config/config.module.js.map +0 -1
  163. package/dist/enforcement/classification.decorator.js.map +0 -1
  164. package/dist/enforcement/core-seam.js.map +0 -1
  165. package/dist/enforcement/enforcement.module.js.map +0 -1
  166. package/dist/enforcement/global-enforcement.guard.js.map +0 -1
  167. package/dist/enforcement/reenroll-allowlist.js.map +0 -1
  168. package/dist/identity/auth-facts.controller.js.map +0 -1
  169. package/dist/identity/identity-actions.js.map +0 -1
  170. package/dist/identity/module-registry.bridge.js.map +0 -1
  171. package/dist/identity/permission-facts.adapter.js.map +0 -1
  172. package/dist/index.js.map +0 -1
  173. package/dist/migrations-dir.js.map +0 -1
  174. package/dist/module/shield-tokens.js.map +0 -1
  175. package/dist/module/shield.module.js.map +0 -1
  176. package/dist/request-auth/request-principal.js.map +0 -1
  177. package/dist/request-auth/session-facts-als.js.map +0 -1
  178. package/dist/request-auth/session-token-binder.js.map +0 -1
  179. package/dist/request-auth/tokens.js.map +0 -1
  180. package/dist/scope/closure-builder.js.map +0 -1
  181. package/dist/session/crypto-adapters.js.map +0 -1
  182. package/dist/session/index.js.map +0 -1
  183. package/dist/session/mariadb-session-record-store.js.map +0 -1
  184. package/dist/session/redis-hot-session-store.js.map +0 -1
@@ -1,16 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_REDIS_URL = exports.DEFAULT_DB_CONFIG = exports.SCHEMA_SVC_POOL_HARD_CAP = exports.DEFAULT_POOL_BUDGET = void 0;
3
+ exports.DEFAULT_REDIS_URL = exports.DEFAULT_DB_CONFIG = exports.SCHEMA_SVC_POOL_HARD_CAP = exports.DEFAULT_POOL_BUDGET = exports.DEFAULT_REDIS_COMMAND_TIMEOUT_MS = exports.DEFAULT_LOGIN_IP_LIMIT = void 0;
4
+ exports.isDevOrTestNodeEnv = isDevOrTestNodeEnv;
4
5
  exports.loadPoolBudget = loadPoolBudget;
5
6
  exports.loadDbConfig = loadDbConfig;
6
7
  exports.loadRedisConfig = loadRedisConfig;
7
8
  exports.loadSessionLifetimes = loadSessionLifetimes;
9
+ exports.loadLoginIpLimit = loadLoginIpLimit;
8
10
  exports.loadRefreshHmacKey = loadRefreshHmacKey;
9
11
  exports.assertRefreshHmacKey = assertRefreshHmacKey;
10
12
  exports.loadAppConfig = loadAppConfig;
11
13
  exports.loadAppConfigAsync = loadAppConfigAsync;
12
14
  exports.evaluatePoolBudget = evaluatePoolBudget;
13
15
  const core_1 = require("@astram/core");
16
+ /** Plan D3 default: 50 failed logins per client IP per 15 minutes. */
17
+ exports.DEFAULT_LOGIN_IP_LIMIT = {
18
+ maxAttempts: 50,
19
+ windowMs: 15 * 60 * 1000,
20
+ };
21
+ /** Default Redis per-command timeout (GAPS #7): the request path fails closed within ~2 s. */
22
+ exports.DEFAULT_REDIS_COMMAND_TIMEOUT_MS = 2000;
14
23
  /** Documented MVP single-node defaults (see the class doc + my.cnf). */
15
24
  exports.DEFAULT_POOL_BUDGET = {
16
25
  workers: 8,
@@ -30,11 +39,27 @@ function parsePort(raw, fallback) {
30
39
  const n = Number.parseInt(raw, 10);
31
40
  return Number.isInteger(n) && n > 0 && n < 65_536 ? n : fallback;
32
41
  }
42
+ /**
43
+ * The `environment` LABEL (logging / display). NOTE: it maps an UNSET `NODE_ENV` to
44
+ * `'development'`, so it must NEVER drive a security default — an unset env is not
45
+ * proof of a dev box. Security-relevant choices (e.g. the dev `LogEmailSender`, which
46
+ * prints live reset links) read the RAW `NODE_ENV` instead: see
47
+ * {@link isDevOrTestNodeEnv}.
48
+ */
33
49
  function parseEnvironment(raw) {
34
50
  if (raw === 'production' || raw === 'test')
35
51
  return raw;
36
52
  return 'development';
37
53
  }
54
+ /**
55
+ * `true` ONLY when the RAW `NODE_ENV` is explicitly `'development'` or `'test'`.
56
+ * Unset, blank, `'production'`, `'staging'` or anything else ⇒ `false`. Used to gate
57
+ * dev-only defaults that would leak secrets in a real deployment (fail closed: an
58
+ * unset NODE_ENV is treated as "not dev").
59
+ */
60
+ function isDevOrTestNodeEnv(raw) {
61
+ return raw === 'development' || raw === 'test';
62
+ }
38
63
  /**
39
64
  * Parse a positive integer from env with a fallback. Returns the fallback for
40
65
  * missing/blank/non-integer/≤0 values — the boot assertion is the authority on
@@ -126,18 +151,19 @@ function loadDbConfig(env = process.env) {
126
151
  * documented default.
127
152
  */
128
153
  function loadRedisConfig(env = process.env) {
154
+ const commandTimeoutMs = parsePositiveInt(env.REDIS_COMMAND_TIMEOUT_MS, exports.DEFAULT_REDIS_COMMAND_TIMEOUT_MS);
129
155
  const explicit = env.REDIS_URL;
130
156
  if (explicit !== undefined && explicit.trim() !== '') {
131
- return { url: explicit.trim() };
157
+ return { url: explicit.trim(), commandTimeoutMs };
132
158
  }
133
159
  const host = env.REDIS_HOST;
134
160
  const port = env.REDIS_PORT;
135
161
  if ((host !== undefined && host.trim() !== '') || (port !== undefined && port.trim() !== '')) {
136
162
  const h = parseString(host, 'localhost');
137
163
  const p = parsePort(port, 6379);
138
- return { url: `redis://${h}:${p}` };
164
+ return { url: `redis://${h}:${p}`, commandTimeoutMs };
139
165
  }
140
- return { url: exports.DEFAULT_REDIS_URL };
166
+ return { url: exports.DEFAULT_REDIS_URL, commandTimeoutMs };
141
167
  }
142
168
  /**
143
169
  * Build the optional {@link SessionLifetimeOverrides} from env. Every field is
@@ -162,6 +188,19 @@ function loadSessionLifetimes(env = process.env) {
162
188
  overrides.refreshAbsoluteCapMs = cap;
163
189
  return overrides;
164
190
  }
191
+ /**
192
+ * Build the per-client-IP login failure limit from env (plan D3). Each field falls back
193
+ * to {@link DEFAULT_LOGIN_IP_LIMIT} when absent/blank/invalid.
194
+ * Field ↔ env name:
195
+ * maxAttempts ← LOGIN_IP_MAX_FAILURES (default 50)
196
+ * windowMs ← LOGIN_IP_WINDOW_MS (default 900000 = 15 min)
197
+ */
198
+ function loadLoginIpLimit(env = process.env) {
199
+ return {
200
+ maxAttempts: parsePositiveInt(env.LOGIN_IP_MAX_FAILURES, exports.DEFAULT_LOGIN_IP_LIMIT.maxAttempts),
201
+ windowMs: parsePositiveInt(env.LOGIN_IP_WINDOW_MS, exports.DEFAULT_LOGIN_IP_LIMIT.windowMs),
202
+ };
203
+ }
165
204
  /**
166
205
  * Read the refresh-token HMAC key from env (raw; `''` when absent/blank). Total —
167
206
  * does NOT throw, so {@link loadAppConfig} stays boot-safe for a non-auth scaffold.
@@ -214,6 +253,7 @@ function loadAppConfig(env = process.env) {
214
253
  redis: loadRedisConfig(env),
215
254
  refreshHmacKey: loadRefreshHmacKey(env),
216
255
  lifetimes: loadSessionLifetimes(env),
256
+ loginIpLimit: loadLoginIpLimit(env),
217
257
  };
218
258
  }
219
259
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"local-kek-crypto.d.ts","sourceRoot":"","sources":["../../src/encryption/local-kek-crypto.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAO5E,qBAAa,cAAe,YAAW,UAAU;;IAG/C;;;OAGG;gBACS,GAAG,EAAE,UAAU;IAOrB,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC;IAI3B,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAIxD,SAAS,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAI9D,OAAO,CAAC,SAAS,EAAE,UAAU,EAAE,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAI/E,OAAO,CAAC,UAAU,EAAE,UAAU,EAAE,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;CA0BvF"}
1
+ {"version":3,"file":"local-kek-crypto.d.ts","sourceRoot":"","sources":["../../src/encryption/local-kek-crypto.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAO5E,qBAAa,cAAe,YAAW,UAAU;;IAG/C;;;OAGG;gBACS,GAAG,EAAE,UAAU;IAOrB,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC;IAI3B,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAIxD,SAAS,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAI9D,OAAO,CAAC,SAAS,EAAE,UAAU,EAAE,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAI/E,OAAO,CAAC,UAAU,EAAE,UAAU,EAAE,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;CA2BvF"}
@@ -1 +1 @@
1
- {"version":3,"file":"enforcement.module.d.ts","sourceRoot":"","sources":["../../src/enforcement/enforcement.module.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AACH,qBAQa,iBAAiB;CAAG"}
1
+ {"version":3,"file":"enforcement.module.d.ts","sourceRoot":"","sources":["../../src/enforcement/enforcement.module.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;GAcG;AACH,qBAgBa,iBAAiB;CAAG"}
@@ -10,6 +10,7 @@ exports.EnforcementModule = void 0;
10
10
  const common_1 = require("@nestjs/common");
11
11
  const core_1 = require("@nestjs/core");
12
12
  const global_enforcement_guard_js_1 = require("./global-enforcement.guard.js");
13
+ const engine_error_filter_js_1 = require("./engine-error.filter.js");
13
14
  /**
14
15
  * The SOLE definition of the global `APP_GUARD` binding (ADR-0002).
15
16
  *
@@ -35,6 +36,14 @@ exports.EnforcementModule = EnforcementModule = __decorate([
35
36
  provide: core_1.APP_GUARD,
36
37
  useClass: global_enforcement_guard_js_1.GlobalEnforcementGuard,
37
38
  },
39
+ // The framework-wide engine error → HTTP mapping (security hardening Phase 1C): an engine
40
+ // deny / validation / unavailable error from any consumer controller answers 403 / 400 /
41
+ // 503 instead of 500. Bound here, beside the one APP_GUARD, so forRoot and forRootAsync
42
+ // (which both compose this module) register it exactly once.
43
+ {
44
+ provide: core_1.APP_FILTER,
45
+ useClass: engine_error_filter_js_1.EngineErrorFilter,
46
+ },
38
47
  ],
39
48
  })
40
49
  ], EnforcementModule);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * EngineErrorFilter — the framework-wide mapping of `@astram/engine` errors to HTTP
3
+ * (security hardening Phase 1C). Registered ONCE as a global `APP_FILTER` by
4
+ * `ShieldModule.forRoot` / `forRootAsync`.
5
+ *
6
+ * A consumer feature controller that writes through `RecordsService` sees the engine's typed,
7
+ * fail-closed errors. Unmapped, Nest answered every one of them with a 500. This filter answers:
8
+ *
9
+ * AuthorizationDeniedError (incl. ADR-0034 `create_outside_write_scope`) → 403
10
+ * ValidationError → 400
11
+ * EngineUnavailableError → 503
12
+ *
13
+ * with FIXED, id-free messages (ADR-0012): the engine's own message carries reason codes, record
14
+ * ids and detail meant for logs, never for clients. There is deliberately no 404 mapping: the
15
+ * engine has no not-found error, because a missing or other-tenant row is folded into a deny so
16
+ * that it cannot be probed (`fetch-record.ts`).
17
+ *
18
+ * ── WHAT IT LEAVES ALONE ───────────────────────────────────────────────────────────────────
19
+ * `@Catch` lists only {@link EngineHttpMappableError}, so Nest hands this filter nothing else:
20
+ * `HttpException`s, `CapabilityNotSupportedError` / `RegistrationError` (framework gaps and
21
+ * misconfiguration — a 500 is right), and every non-engine error keep Nest's default handling and
22
+ * any consumer filter. Controller/method filters and interceptors run first, so identity's
23
+ * `IdentityErrorInterceptor` maps its routes before this filter is consulted.
24
+ *
25
+ * ── WHY STRUCTURAL MATCHING (not `instanceof` the engine classes) ──────────────────────────────
26
+ * Guard depends on `@astram/engine`, but this file is statically imported by `shield.module.ts`,
27
+ * whose own compiled output is kept free of runtime engine/core requires (see its header). A
28
+ * structural match also keeps working when a consumer's tree holds two copies of
29
+ * `@astram/engine` (version skew), where `instanceof` silently fails and the 500 returns. An
30
+ * error matches only when ALL hold: it is an `Error`, its prototype chain contains a class named
31
+ * `EngineError` (the engine's base), its `name` is one of the three classes above (the engine
32
+ * sets `name = new.target.name`), and it carries that class's field (`reason` / `field`). A
33
+ * third-party error that merely shares a name is not mapped.
34
+ */
35
+ import { type ArgumentsHost } from '@nestjs/common';
36
+ import { BaseExceptionFilter } from '@nestjs/core';
37
+ type EngineErrorKind = 'denied' | 'invalid' | 'unavailable';
38
+ /** Classify an engine error this filter maps, or `null` for anything else. */
39
+ export declare function classifyEngineError(err: unknown): EngineErrorKind | null;
40
+ /**
41
+ * The `@Catch` target: Nest selects a filter with `exception instanceof <target>`, and
42
+ * `Symbol.hasInstance` makes that the structural {@link classifyEngineError} test.
43
+ */
44
+ export declare class EngineHttpMappableError {
45
+ static [Symbol.hasInstance](value: unknown): boolean;
46
+ }
47
+ /**
48
+ * Map an engine error to the HTTP exception the framework answers with, or return it unchanged
49
+ * when it is not a mapped engine error. Exported for unit tests and for callers mapping by hand.
50
+ */
51
+ export declare function toEngineHttpError(err: unknown): unknown;
52
+ /** The global filter: answers a mapped engine error through Nest's own HttpException reply. */
53
+ export declare class EngineErrorFilter extends BaseExceptionFilter {
54
+ catch(exception: unknown, host: ArgumentsHost): void;
55
+ }
56
+ export {};
57
+ //# sourceMappingURL=engine-error.filter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine-error.filter.d.ts","sourceRoot":"","sources":["../../src/enforcement/engine-error.filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,EAML,KAAK,aAAa,EACnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,KAAK,eAAe,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,CAAC;AAa5D,8EAA8E;AAC9E,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,eAAe,GAAG,IAAI,CAaxE;AAED;;;GAGG;AACH,qBAAa,uBAAuB;IAClC,MAAM,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO;CAGrD;AAKD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAsBvD;AAED,+FAA+F;AAC/F,qBACa,iBAAkB,SAAQ,mBAAmB;IAC/C,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,aAAa,GAAG,IAAI;CAG9D"}
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.EngineErrorFilter = exports.EngineHttpMappableError = void 0;
10
+ exports.classifyEngineError = classifyEngineError;
11
+ exports.toEngineHttpError = toEngineHttpError;
12
+ /**
13
+ * EngineErrorFilter — the framework-wide mapping of `@astram/engine` errors to HTTP
14
+ * (security hardening Phase 1C). Registered ONCE as a global `APP_FILTER` by
15
+ * `ShieldModule.forRoot` / `forRootAsync`.
16
+ *
17
+ * A consumer feature controller that writes through `RecordsService` sees the engine's typed,
18
+ * fail-closed errors. Unmapped, Nest answered every one of them with a 500. This filter answers:
19
+ *
20
+ * AuthorizationDeniedError (incl. ADR-0034 `create_outside_write_scope`) → 403
21
+ * ValidationError → 400
22
+ * EngineUnavailableError → 503
23
+ *
24
+ * with FIXED, id-free messages (ADR-0012): the engine's own message carries reason codes, record
25
+ * ids and detail meant for logs, never for clients. There is deliberately no 404 mapping: the
26
+ * engine has no not-found error, because a missing or other-tenant row is folded into a deny so
27
+ * that it cannot be probed (`fetch-record.ts`).
28
+ *
29
+ * ── WHAT IT LEAVES ALONE ───────────────────────────────────────────────────────────────────
30
+ * `@Catch` lists only {@link EngineHttpMappableError}, so Nest hands this filter nothing else:
31
+ * `HttpException`s, `CapabilityNotSupportedError` / `RegistrationError` (framework gaps and
32
+ * misconfiguration — a 500 is right), and every non-engine error keep Nest's default handling and
33
+ * any consumer filter. Controller/method filters and interceptors run first, so identity's
34
+ * `IdentityErrorInterceptor` maps its routes before this filter is consulted.
35
+ *
36
+ * ── WHY STRUCTURAL MATCHING (not `instanceof` the engine classes) ──────────────────────────────
37
+ * Guard depends on `@astram/engine`, but this file is statically imported by `shield.module.ts`,
38
+ * whose own compiled output is kept free of runtime engine/core requires (see its header). A
39
+ * structural match also keeps working when a consumer's tree holds two copies of
40
+ * `@astram/engine` (version skew), where `instanceof` silently fails and the 500 returns. An
41
+ * error matches only when ALL hold: it is an `Error`, its prototype chain contains a class named
42
+ * `EngineError` (the engine's base), its `name` is one of the three classes above (the engine
43
+ * sets `name = new.target.name`), and it carries that class's field (`reason` / `field`). A
44
+ * third-party error that merely shares a name is not mapped.
45
+ */
46
+ const common_1 = require("@nestjs/common");
47
+ const core_1 = require("@nestjs/core");
48
+ /** True when `err`'s prototype chain contains the engine's `EngineError` base class. */
49
+ function isEngineFamily(err) {
50
+ let proto = Object.getPrototypeOf(err);
51
+ while (proto !== null && proto !== Error.prototype && proto !== Object.prototype) {
52
+ const ctor = proto.constructor;
53
+ if (ctor?.name === 'EngineError')
54
+ return true;
55
+ proto = Object.getPrototypeOf(proto);
56
+ }
57
+ return false;
58
+ }
59
+ /** Classify an engine error this filter maps, or `null` for anything else. */
60
+ function classifyEngineError(err) {
61
+ if (!(err instanceof Error) || !isEngineFamily(err))
62
+ return null;
63
+ const fields = err;
64
+ switch (err.name) {
65
+ case 'AuthorizationDeniedError':
66
+ return typeof fields.reason === 'string' ? 'denied' : null;
67
+ case 'ValidationError':
68
+ return typeof fields.field === 'string' ? 'invalid' : null;
69
+ case 'EngineUnavailableError':
70
+ return 'unavailable';
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * The `@Catch` target: Nest selects a filter with `exception instanceof <target>`, and
77
+ * `Symbol.hasInstance` makes that the structural {@link classifyEngineError} test.
78
+ */
79
+ class EngineHttpMappableError {
80
+ static [Symbol.hasInstance](value) {
81
+ return classifyEngineError(value) !== null;
82
+ }
83
+ }
84
+ exports.EngineHttpMappableError = EngineHttpMappableError;
85
+ /** A field name safe to echo (a declared column-style identifier); anything else is not echoed. */
86
+ const ECHOABLE_FIELD = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
87
+ /**
88
+ * Map an engine error to the HTTP exception the framework answers with, or return it unchanged
89
+ * when it is not a mapped engine error. Exported for unit tests and for callers mapping by hand.
90
+ */
91
+ function toEngineHttpError(err) {
92
+ if (err instanceof common_1.HttpException)
93
+ return err;
94
+ switch (classifyEngineError(err)) {
95
+ case 'denied':
96
+ return err.reason === 'create_outside_write_scope'
97
+ ? new common_1.ForbiddenException('You cannot create this record: it is outside the organisations you manage.')
98
+ : new common_1.ForbiddenException('You do not have access to perform this action.');
99
+ case 'invalid': {
100
+ const field = err.field;
101
+ return new common_1.BadRequestException(ECHOABLE_FIELD.test(field) ? `Invalid value for '${field}'.` : 'Invalid input.');
102
+ }
103
+ case 'unavailable':
104
+ return new common_1.ServiceUnavailableException('This operation is temporarily unavailable. Try again.');
105
+ default:
106
+ return err;
107
+ }
108
+ }
109
+ /** The global filter: answers a mapped engine error through Nest's own HttpException reply. */
110
+ let EngineErrorFilter = class EngineErrorFilter extends core_1.BaseExceptionFilter {
111
+ catch(exception, host) {
112
+ super.catch(toEngineHttpError(exception), host);
113
+ }
114
+ };
115
+ exports.EngineErrorFilter = EngineErrorFilter;
116
+ exports.EngineErrorFilter = EngineErrorFilter = __decorate([
117
+ (0, common_1.Catch)(EngineHttpMappableError)
118
+ ], EngineErrorFilter);
@@ -1,8 +1,9 @@
1
1
  import { CanActivate, ExecutionContext } from '@nestjs/common';
2
2
  import { Reflector } from '@nestjs/core';
3
3
  import type { CoreDecisionAuthority } from './core-seam.js';
4
- import type { SessionTokenBinder } from '../request-auth/session-token-binder.js';
4
+ import { type SessionTokenBinder } from '../request-auth/session-token-binder.js';
5
5
  import type { ServiceAuthenticator } from '@astram/core';
6
+ import type { ScopeSubtreeResolver } from '../auth/ports/scope-subtree-resolver.js';
6
7
  /**
7
8
  * Global, fail-closed enforcement guard (ADR-0002) — now with REAL request
8
9
  * authorization for classified routes.
@@ -35,9 +36,35 @@ export declare class GlobalEnforcementGuard implements CanActivate {
35
36
  private readonly decisionAuthority?;
36
37
  private readonly tokenBinder?;
37
38
  private readonly serviceAuth?;
38
- constructor(reflector: Reflector, decisionAuthority?: CoreDecisionAuthority | undefined, tokenBinder?: SessionTokenBinder | undefined, serviceAuth?: ServiceAuthenticator | undefined);
39
+ private readonly scopeSubtreeResolver?;
40
+ constructor(reflector: Reflector, decisionAuthority?: CoreDecisionAuthority | undefined, tokenBinder?: SessionTokenBinder | undefined, serviceAuth?: ServiceAuthenticator | undefined, scopeSubtreeResolver?: (ScopeSubtreeResolver | null) | undefined);
39
41
  canActivate(context: ExecutionContext): Promise<boolean>;
40
42
  /** The real authorization path for a classified route (fail-closed throughout). */
41
43
  private authorizeClassified;
44
+ /**
45
+ * Write gate 5's reloaded grants and the live roles onto the session binding (ADR-0035). Session
46
+ * bindings only: a service-account key has no binding (its roles and scope are read live per
47
+ * request by the key store). Best-effort and conditional (same session, same tenant, fresher
48
+ * facts, `XX`): a miss or a Redis error only means the next request reloads again.
49
+ */
50
+ private rebindReloadedGrants;
51
+ /**
52
+ * Re-derive the session's scope subtree after gate 5 reloaded facts at `reloadedVersion`
53
+ * (security hardening Phase 1C). Identity bumps a tenant's permission version when a company is
54
+ * created, moved or deleted under it, so a reload at a version newer than the bound subtree's
55
+ * stamp means the subtree may be stale: resolve it again with the SAME resolver login uses,
56
+ * rebind it (token + session entries) and return the principal the handler should see.
57
+ *
58
+ * Off the hot path: runs only for a session binding that carries a subtree, with a resolver
59
+ * wired, and a reload NEWER than its stamp (or no stamp yet — once, then stamped). That is the
60
+ * only DB read added (one closure query), and only on a version change.
61
+ *
62
+ * FAIL-CLOSED, NON-WIDENING: a resolver error, a non-array or an EMPTY result →
63
+ * {@link SUBTREE_REDERIVE_FAILED}: the bound subtree stays (never replaced by a guess, never
64
+ * widened), the stamp is not advanced, the reloaded facts are not cached (ADR-0035), and the next
65
+ * request reloads and retries. `null` = no re-derive was due. A failed Redis rebind still returns the fresh principal for this request
66
+ * (the DB answer is authoritative); the next reload retries the rebind.
67
+ */
68
+ private rederiveSubtreeOnVersionMove;
42
69
  }
43
70
  //# sourceMappingURL=global-enforcement.guard.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"global-enforcement.guard.d.ts","sourceRoot":"","sources":["../../src/enforcement/global-enforcement.guard.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,gBAAgB,EAMjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAOzC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAK5D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAQzD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBACa,sBAAuB,YAAW,WAAW;IAEtD,OAAO,CAAC,QAAQ,CAAC,SAAS;IAG1B,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC;IAGnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAM7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBAZZ,SAAS,EAAE,SAAS,EAGpB,iBAAiB,CAAC,EAAE,qBAAqB,YAAA,EAGzC,WAAW,CAAC,EAAE,kBAAkB,YAAA,EAMhC,WAAW,CAAC,EAAE,oBAAoB,YAAA;IAG/C,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;IAuC9D,mFAAmF;YACrE,mBAAmB;CA0FlC"}
1
+ {"version":3,"file":"global-enforcement.guard.d.ts","sourceRoot":"","sources":["../../src/enforcement/global-enforcement.guard.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,gBAAgB,EAMjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAOzC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAK5D,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,yCAAyC,CAAC;AACjD,OAAO,KAAK,EAA2B,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAOlF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yCAAyC,CAAC;AAoBpF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBACa,sBAAuB,YAAW,WAAW;IAEtD,OAAO,CAAC,QAAQ,CAAC,SAAS;IAG1B,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC;IAGnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAM7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAM7B,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAC;gBAlBrB,SAAS,EAAE,SAAS,EAGpB,iBAAiB,CAAC,EAAE,qBAAqB,YAAA,EAGzC,WAAW,CAAC,EAAE,kBAAkB,YAAA,EAMhC,WAAW,CAAC,EAAE,oBAAoB,YAAA,EAMlC,oBAAoB,CAAC,GAAE,oBAAoB,GAAG,IAAI,aAAA;IAG/D,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;IAuC9D,mFAAmF;YACrE,mBAAmB;IAyIjC;;;;;OAKG;YACW,oBAAoB;IAuBlC;;;;;;;;;;;;;;;;OAgBG;YACW,4BAA4B;CA8C3C"}
@@ -18,9 +18,24 @@ const core_1 = require("@nestjs/core");
18
18
  const classification_decorator_js_1 = require("./classification.decorator.js");
19
19
  const shield_tokens_js_1 = require("../module/shield-tokens.js");
20
20
  const tokens_js_1 = require("../request-auth/tokens.js");
21
+ const session_token_binder_js_1 = require("../request-auth/session-token-binder.js");
21
22
  const request_principal_js_1 = require("../request-auth/request-principal.js");
23
+ const tokens_js_2 = require("../auth/tokens.js");
22
24
  const session_facts_als_js_1 = require("../request-auth/session-facts-als.js");
23
25
  const reenroll_allowlist_js_1 = require("./reenroll-allowlist.js");
26
+ /** A subtree re-derive that was due but could not be done (resolver error / empty / junk). */
27
+ const SUBTREE_REDERIVE_FAILED = Symbol('subtree-rederive-failed');
28
+ /**
29
+ * The binding's cached facts as gate 5 may use them (ADR-0035): absent, or at/past their
30
+ * `validUntilMs` time bound ⇒ `null` (gate 5 reloads). Never fabricated.
31
+ */
32
+ function usableCachedFacts(facts, nowMs) {
33
+ if (facts === undefined)
34
+ return null;
35
+ if (typeof facts.validUntilMs === 'number' && nowMs >= facts.validUntilMs)
36
+ return null;
37
+ return facts;
38
+ }
24
39
  /**
25
40
  * Global, fail-closed enforcement guard (ADR-0002) — now with REAL request
26
41
  * authorization for classified routes.
@@ -53,11 +68,13 @@ let GlobalEnforcementGuard = class GlobalEnforcementGuard {
53
68
  decisionAuthority;
54
69
  tokenBinder;
55
70
  serviceAuth;
56
- constructor(reflector, decisionAuthority, tokenBinder, serviceAuth) {
71
+ scopeSubtreeResolver;
72
+ constructor(reflector, decisionAuthority, tokenBinder, serviceAuth, scopeSubtreeResolver) {
57
73
  this.reflector = reflector;
58
74
  this.decisionAuthority = decisionAuthority;
59
75
  this.tokenBinder = tokenBinder;
60
76
  this.serviceAuth = serviceAuth;
77
+ this.scopeSubtreeResolver = scopeSubtreeResolver;
61
78
  }
62
79
  async canActivate(context) {
63
80
  // Only HTTP requests are classified here. Non-HTTP contexts (if ever added)
@@ -92,7 +109,7 @@ let GlobalEnforcementGuard = class GlobalEnforcementGuard {
92
109
  // Absent → we cannot authenticate the caller → DENY (never allow-through).
93
110
  if (this.tokenBinder === undefined || this.decisionAuthority === undefined) {
94
111
  throw new common_1.ForbiddenException('Shield enforcement: this route is @Classified but request authentication is not ' +
95
- "enabled (ShieldModule.forRoot({ auth: true })). It is denied by default.");
112
+ 'enabled (ShieldModule.forRoot({ auth: true })). It is denied by default.');
96
113
  }
97
114
  const req = context.switchToHttp().getRequest();
98
115
  const authHeader = req?.headers?.['authorization'];
@@ -138,17 +155,136 @@ let GlobalEnforcementGuard = class GlobalEnforcementGuard {
138
155
  // FAIL-CLOSED: no projector wired / absent / malformed binding → `claims.cachedFacts`
139
156
  // is undefined → the store carries `cachedFacts: null` → gate 5 DB-reloads (today's
140
157
  // behavior). The cache is only ever consulted; it never fabricates a permit.
158
+ //
159
+ // RELOAD SIGNAL (security hardening Phase 1C): when gate 5 reloads THIS principal's grants,
160
+ // the framework-built authority reports the reloaded (authoritative) version here.
161
+ //
162
+ // TIME BOUND (ADR-0035): cached facts at or past their `validUntilMs` (the next start or end of
163
+ // a role posting's window — a change no version bump marks) are treated as ABSENT, so gate 5
164
+ // reloads them (and the roles with them, below).
165
+ let reloaded;
166
+ const usableFacts = usableCachedFacts(claims.cachedFacts, Date.now());
141
167
  const factsStore = {
142
- cachedFacts: claims.cachedFacts ?? null,
143
- permissionsVersion: claims.cachedFacts?.permissionsVersion ?? 0,
168
+ cachedFacts: usableFacts,
169
+ permissionsVersion: usableFacts?.permissionsVersion ?? 0,
170
+ onReload: (principalId, tenantId, grants) => {
171
+ if (principalId === principal.id && tenantId === principal.tenantId) {
172
+ reloaded = grants;
173
+ }
174
+ },
144
175
  };
145
176
  const decision = await session_facts_als_js_1.SHIELD_SESSION_FACTS_ALS.run(factsStore, () => this.decisionAuthority.decide(principal, { key: classification.action }, { level: 'object_type', typeKey: classification.typeKey, tenantId: principal.tenantId }, { isAuthenticated: true }));
177
+ // The type-level decision above is on the principal's OWN tenant, which every subtree
178
+ // contains, so it does not depend on the subtree; the handler (engine record checks, the
179
+ // ADR-0034 create wall) is what reads it. Hence: decide first, then hand the handler the
180
+ // re-derived principal.
181
+ //
182
+ // LIVE ROLES (ADR-0035, GAPS #4): the reload also carries the principal's in-effect roles (when
183
+ // the app port reports them). The handler gets them on THIS request (role-keyed record rules,
184
+ // list scope, shares, the engine's record checks all read `req.principal.roleIds`), and the
185
+ // binding is rewritten with the reloaded facts + roles together, so the next requests are
186
+ // served from the cache (GAPS #6) with the same live roles.
187
+ if (reloaded !== undefined) {
188
+ const liveRoles = (0, session_token_binder_js_1.liveRoleIds)(reloaded);
189
+ const current = liveRoles !== undefined ? { ...claims, roleIds: liveRoles } : claims;
190
+ const rederived = await this.rederiveSubtreeOnVersionMove(authHeader, current, reloaded.permissionsVersion);
191
+ // A subtree re-derive that was due but FAILED (resolver error / empty) keeps the facts
192
+ // uncached, so the next request reloads and retries it (the Phase 1C retry guarantee).
193
+ if (rederived !== SUBTREE_REDERIVE_FAILED) {
194
+ await this.rebindReloadedGrants(authHeader, claims, reloaded, liveRoles);
195
+ }
196
+ if (rederived !== null && rederived !== SUBTREE_REDERIVE_FAILED) {
197
+ req.principal = rederived;
198
+ }
199
+ else if (liveRoles !== undefined) {
200
+ const withRoles = (0, request_principal_js_1.principalFromClaims)(current);
201
+ if (withRoles !== null)
202
+ req.principal = withRoles;
203
+ }
204
+ }
146
205
  if (decision.effect === 'allow') {
147
206
  return true;
148
207
  }
149
208
  // Deny (or any non-allow) → 403. Do NOT leak the reason detail to the caller.
150
209
  throw new common_1.ForbiddenException(`Shield enforcement: access denied for ${classification.action} on ${classification.typeKey}.`);
151
210
  }
211
+ /**
212
+ * Write gate 5's reloaded grants and the live roles onto the session binding (ADR-0035). Session
213
+ * bindings only: a service-account key has no binding (its roles and scope are read live per
214
+ * request by the key store). Best-effort and conditional (same session, same tenant, fresher
215
+ * facts, `XX`): a miss or a Redis error only means the next request reloads again.
216
+ */
217
+ async rebindReloadedGrants(authHeader, claims, grants, liveRoles) {
218
+ if (this.tokenBinder === undefined)
219
+ return;
220
+ if (claims.sessionId === '' || claims.kind === 'service_account')
221
+ return;
222
+ const token = (0, request_principal_js_1.extractBearerToken)(authHeader);
223
+ if (token === null)
224
+ return;
225
+ try {
226
+ await this.tokenBinder.refreshGrants(token, claims.sessionId, claims.tenantId, grants, liveRoles);
227
+ }
228
+ catch {
229
+ // Best-effort: the decision and the handler's principal already use the reloaded grants.
230
+ }
231
+ }
232
+ /**
233
+ * Re-derive the session's scope subtree after gate 5 reloaded facts at `reloadedVersion`
234
+ * (security hardening Phase 1C). Identity bumps a tenant's permission version when a company is
235
+ * created, moved or deleted under it, so a reload at a version newer than the bound subtree's
236
+ * stamp means the subtree may be stale: resolve it again with the SAME resolver login uses,
237
+ * rebind it (token + session entries) and return the principal the handler should see.
238
+ *
239
+ * Off the hot path: runs only for a session binding that carries a subtree, with a resolver
240
+ * wired, and a reload NEWER than its stamp (or no stamp yet — once, then stamped). That is the
241
+ * only DB read added (one closure query), and only on a version change.
242
+ *
243
+ * FAIL-CLOSED, NON-WIDENING: a resolver error, a non-array or an EMPTY result →
244
+ * {@link SUBTREE_REDERIVE_FAILED}: the bound subtree stays (never replaced by a guess, never
245
+ * widened), the stamp is not advanced, the reloaded facts are not cached (ADR-0035), and the next
246
+ * request reloads and retries. `null` = no re-derive was due. A failed Redis rebind still returns the fresh principal for this request
247
+ * (the DB answer is authoritative); the next reload retries the rebind.
248
+ */
249
+ async rederiveSubtreeOnVersionMove(authHeader, claims, reloadedVersion) {
250
+ const resolver = this.scopeSubtreeResolver;
251
+ if (resolver === undefined || resolver === null || this.tokenBinder === undefined)
252
+ return null;
253
+ // Session bindings only: a service-account key has no binding (its scope is read live per
254
+ // request by the key store) and no session id.
255
+ if (claims.sessionId === '' || claims.kind === 'service_account')
256
+ return null;
257
+ // Only a binding that carries a subtree: an absent one is the self-node fallback (no resolver
258
+ // or a failed login-time resolve) and stays that way until the next login/refresh.
259
+ if (claims.scopeSubtreeNodeIds === undefined || claims.scopeSubtreeNodeIds.length === 0) {
260
+ return null;
261
+ }
262
+ const stamp = claims.scopeSubtreeVersion;
263
+ if (stamp !== undefined && reloadedVersion <= stamp)
264
+ return null;
265
+ let subtree;
266
+ try {
267
+ const resolved = await resolver.resolveSubtree(claims.tenantId);
268
+ if (!Array.isArray(resolved) || resolved.length === 0)
269
+ return SUBTREE_REDERIVE_FAILED;
270
+ if (resolved.some((n) => typeof n !== 'string' || n.length === 0)) {
271
+ return SUBTREE_REDERIVE_FAILED;
272
+ }
273
+ subtree = [...resolved];
274
+ }
275
+ catch {
276
+ return SUBTREE_REDERIVE_FAILED;
277
+ }
278
+ const token = (0, request_principal_js_1.extractBearerToken)(authHeader);
279
+ if (token !== null) {
280
+ await this.tokenBinder.rederiveScopeSubtree(token, claims.sessionId, claims.tenantId, subtree, reloadedVersion);
281
+ }
282
+ return (0, request_principal_js_1.principalFromClaims)({
283
+ ...claims,
284
+ scopeSubtreeNodeIds: subtree,
285
+ scopeSubtreeVersion: reloadedVersion,
286
+ });
287
+ }
152
288
  };
153
289
  exports.GlobalEnforcementGuard = GlobalEnforcementGuard;
154
290
  exports.GlobalEnforcementGuard = GlobalEnforcementGuard = __decorate([
@@ -159,5 +295,7 @@ exports.GlobalEnforcementGuard = GlobalEnforcementGuard = __decorate([
159
295
  __param(2, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_TOKEN_BINDER)),
160
296
  __param(3, (0, common_1.Optional)()),
161
297
  __param(3, (0, common_1.Inject)(tokens_js_1.SHIELD_SERVICE_AUTHENTICATOR)),
162
- __metadata("design:paramtypes", [core_1.Reflector, Object, Function, Object])
298
+ __param(4, (0, common_1.Optional)()),
299
+ __param(4, (0, common_1.Inject)(tokens_js_2.SHIELD_SCOPE_SUBTREE_RESOLVER)),
300
+ __metadata("design:paramtypes", [core_1.Reflector, Object, Function, Object, Object])
163
301
  ], GlobalEnforcementGuard);
@@ -1 +1 @@
1
- {"version":3,"file":"auth-facts.controller.d.ts","sourceRoot":"","sources":["../../src/identity/auth-facts.controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAGlF,OAAO,EAAiC,KAAK,kBAAkB,EAAE,KAAK,6BAA6B,EAAE,MAAM,+BAA+B,CAAC;AAE3I,qBACa,mBAAmB;IAEqB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADD,WAAW,EAAE,kBAAkB,GAAG,SAAS,EAC/C,KAAK,EAAE,6BAA6B,GAAG,SAAS;IAK1G,YAAY,CAA2B,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAiB3F,qBAAqB,CAA2B,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAgB3G,qFAAqF;YACvE,YAAY;CAU3B"}
1
+ {"version":3,"file":"auth-facts.controller.d.ts","sourceRoot":"","sources":["../../src/identity/auth-facts.controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAaH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAGlF,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,6BAA6B,EACnC,MAAM,+BAA+B,CAAC;AAEvC,qBACa,mBAAmB;IAI5B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAG5B,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAHL,WAAW,EAAE,kBAAkB,GAAG,SAAS,EAG3C,KAAK,EAAE,6BAA6B,GAAG,SAAS;IAK7D,YAAY,CACU,aAAa,CAAC,EAAE,MAAM,GAC/C,OAAO,CAAC,kBAAkB,CAAC;IAuBxB,qBAAqB,CACC,aAAa,CAAC,EAAE,MAAM,GAC/C,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAsB/B,qFAAqF;YACvE,YAAY;CAU3B"}
@@ -1 +1 @@
1
- {"version":3,"file":"permission-facts.adapter.d.ts","sourceRoot":"","sources":["../../src/identity/permission-facts.adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAEpC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AACD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CACtC;AAED,yFAAyF;AACzF,qBAAa,6BAA8B,SAAQ,KAAK;gBAC1C,OAAO,SAA8B;CAIlD;AAMD,qBAAa,6BAA6B;IAEtC,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAHH,IAAI,EAAE,IAAI,EACV,WAAW,EAAE,MAAM,SAAS,MAAM,EAAE,EACpC,gBAAgB,EAAE,SAAS,MAAM,EAAE,EACnC,GAAG,GAAE,MAAM,MAAiB;IAGzC,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAqB/F,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;YASlE,oBAAoB;YAcpB,aAAa;YASb,WAAW;IAQzB;;;;;;OAMG;IACH,OAAO,CAAC,eAAe;CAaxB"}
1
+ {"version":3,"file":"permission-facts.adapter.d.ts","sourceRoot":"","sources":["../../src/identity/permission-facts.adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAEpC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AACD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;IACvC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;CACtC;AAED,yFAAyF;AACzF,qBAAa,6BAA8B,SAAQ,KAAK;gBAC1C,OAAO,SAA8B;CAIlD;AAYD,qBAAa,6BAA6B;IAEtC,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAHH,IAAI,EAAE,IAAI,EACV,WAAW,EAAE,MAAM,SAAS,MAAM,EAAE,EACpC,gBAAgB,EAAE,SAAS,MAAM,EAAE,EACnC,GAAG,GAAE,MAAM,MAAiB;IAGzC,UAAU,CACd,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,GACd,OAAO,CAAC,kBAAkB,CAAC;IAmBxB,iBAAiB,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;YASlE,oBAAoB;YAepB,aAAa;YASb,WAAW;IAQzB;;;;;;OAMG;IACH,OAAO,CAAC,eAAe;CAaxB"}
@@ -34,9 +34,7 @@ class MariaDbPermissionFactsAdapter {
34
34
  const roleIds = await this.readRoleIdsForTenant(principalId, tenantId);
35
35
  const granted = roleIds.length > 0 ? await this.readGrantKeys(roleIds) : [];
36
36
  const expanded = this.expandGrantKeys(granted);
37
- const keys = isRoot
38
- ? [...new Set([...expanded, ...this.controlPlaneKeys])]
39
- : expanded;
37
+ const keys = isRoot ? [...new Set([...expanded, ...this.controlPlaneKeys])] : expanded;
40
38
  const permissionsVersion = await this.readVersion(tenantId);
41
39
  return {
42
40
  principalId,
@@ -65,6 +63,7 @@ class MariaDbPermissionFactsAdapter {
65
63
  const nowMs = this.now();
66
64
  const rows = (await this.pool.query(`SELECT DISTINCT mp.mp_role_uuid AS role_uuid
67
65
  FROM \`shield_app\`.\`company_membership\` cm
66
+ JOIN \`shield_app\`.\`users\` u ON u.u_uuid = cm.cm_user_uuid AND u.u_status = 'active'
68
67
  JOIN \`shield_app\`.\`membership_posting\` mp ON mp.mp_membership_uuid = cm.cm_uuid
69
68
  WHERE cm.cm_user_uuid = ? AND cm.cm_company_uuid = ? AND cm.cm_is_active = 1
70
69
  AND (mp.mp_effective_from_ms IS NULL OR mp.mp_effective_from_ms <= ?)