@astram/guard 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/account.controller.d.ts +27 -5
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +99 -26
- package/dist/auth/auth.controller.d.ts +130 -21
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +537 -143
- package/dist/auth/auth.module.d.ts +15 -1
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +212 -30
- package/dist/auth/connections.d.ts +1 -1
- package/dist/auth/connections.d.ts.map +1 -1
- package/dist/auth/connections.js +14 -3
- package/dist/auth/default-session-facts-projector.d.ts +26 -0
- package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
- package/dist/auth/default-session-facts-projector.js +21 -0
- package/dist/auth/mfa-step-up.d.ts +32 -0
- package/dist/auth/mfa-step-up.d.ts.map +1 -0
- package/dist/auth/mfa-step-up.js +109 -0
- package/dist/auth/pending-mfa-store.d.ts +50 -52
- package/dist/auth/pending-mfa-store.d.ts.map +1 -1
- package/dist/auth/pending-mfa-store.js +103 -61
- package/dist/auth/ports/session-facts-projector.d.ts +5 -0
- package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
- package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
- package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
- package/dist/auth/rate-limit/canonical-hint.js +63 -0
- package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
- package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
- package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
- package/dist/auth/tokens.d.ts +70 -11
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +71 -12
- package/dist/authentication/log-email-sender.d.ts +9 -4
- package/dist/authentication/log-email-sender.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.d.ts +5 -1
- package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.js +43 -7
- package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +116 -73
- package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
- package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
- package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
- package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.js +29 -11
- package/dist/authentication/noop-email-sender.d.ts +12 -0
- package/dist/authentication/noop-email-sender.d.ts.map +1 -0
- package/dist/authentication/noop-email-sender.js +9 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
- package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
- package/dist/authz/mariadb-policy-store.js +1 -1
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +1 -4
- package/dist/authz/permission-version-cache.d.ts +38 -0
- package/dist/authz/permission-version-cache.d.ts.map +1 -0
- package/dist/authz/permission-version-cache.js +81 -0
- package/dist/authz/wire-record-authorization.d.ts +25 -1
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +55 -2
- package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
- package/dist/config/app-config.d.ts +36 -0
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +44 -4
- package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
- package/dist/enforcement/enforcement.module.d.ts.map +1 -1
- package/dist/enforcement/enforcement.module.js +9 -0
- package/dist/enforcement/engine-error.filter.d.ts +57 -0
- package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
- package/dist/enforcement/engine-error.filter.js +118 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +143 -5
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +2 -3
- package/dist/index.d.ts +13 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -28
- package/dist/internal.d.ts +12 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/module/shield.module.d.ts +9 -2
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +47 -38
- package/dist/request-auth/session-facts-als.d.ts +16 -1
- package/dist/request-auth/session-facts-als.d.ts.map +1 -1
- package/dist/request-auth/session-facts-als.js +23 -0
- package/dist/request-auth/session-token-binder.d.ts +166 -10
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +408 -28
- package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
- package/dist/secrets/encrypted-file-secrets.js +9 -3
- package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
- package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
- package/dist/session/hot-revoking-session-record-store.js +58 -0
- package/dist/session/index.d.ts +3 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +8 -1
- package/dist/session/mariadb-session-record-store.d.ts +6 -0
- package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
- package/dist/session/mariadb-session-record-store.js +16 -1
- package/dist/session/session-lifetimes.d.ts +30 -0
- package/dist/session/session-lifetimes.d.ts.map +1 -0
- package/dist/session/session-lifetimes.js +21 -0
- package/dist/session/session-restriction-store.d.ts +43 -0
- package/dist/session/session-restriction-store.d.ts.map +1 -0
- package/dist/session/session-restriction-store.js +35 -0
- package/package.json +14 -3
- package/dist/auth/account.controller.js.map +0 -1
- package/dist/auth/auth.controller.js.map +0 -1
- package/dist/auth/auth.module.js.map +0 -1
- package/dist/auth/connections.js.map +0 -1
- package/dist/auth/dto.js.map +0 -1
- package/dist/auth/pending-mfa-store.js.map +0 -1
- package/dist/auth/persistence.module.js.map +0 -1
- package/dist/auth/ports/audit-sink.js.map +0 -1
- package/dist/auth/ports/credential-writer.js.map +0 -1
- package/dist/auth/ports/email-sender.js.map +0 -1
- package/dist/auth/ports/membership-checker.js.map +0 -1
- package/dist/auth/ports/mfa-device-store.js.map +0 -1
- package/dist/auth/ports/profile-writer.js.map +0 -1
- package/dist/auth/ports/recovery-code-store.js.map +0 -1
- package/dist/auth/ports/reset-token-store.js.map +0 -1
- package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
- package/dist/auth/ports/session-facts-projector.js.map +0 -1
- package/dist/auth/ports/session-sweeper.js.map +0 -1
- package/dist/auth/profile-resolver.js.map +0 -1
- package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
- package/dist/auth/tokens.js.map +0 -1
- package/dist/auth/totp-enrollment.js.map +0 -1
- package/dist/authentication/argon2id-password-hasher.js.map +0 -1
- package/dist/authentication/log-email-sender.js.map +0 -1
- package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
- package/dist/authentication/mariadb-credential-writer.js.map +0 -1
- package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
- package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
- package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
- package/dist/authentication/mariadb-profile-writer.js.map +0 -1
- package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
- package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
- package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
- package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
- package/dist/authz/list-scope-sql.js.map +0 -1
- package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
- package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
- package/dist/authz/mariadb-policy-store.js.map +0 -1
- package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
- package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
- package/dist/authz/record-share-cascade.js.map +0 -1
- package/dist/authz/record-share-writer.js.map +0 -1
- package/dist/authz/wire-record-authorization.js.map +0 -1
- package/dist/boot/boot-sequence.js.map +0 -1
- package/dist/boot/boot.module.js.map +0 -1
- package/dist/completeness/catalog-reconciler.js.map +0 -1
- package/dist/completeness/operation-inventory-scanner.js.map +0 -1
- package/dist/completeness/ports/catalog-store.js.map +0 -1
- package/dist/config/app-config.js.map +0 -1
- package/dist/config/config.module.js.map +0 -1
- package/dist/enforcement/classification.decorator.js.map +0 -1
- package/dist/enforcement/core-seam.js.map +0 -1
- package/dist/enforcement/enforcement.module.js.map +0 -1
- package/dist/enforcement/global-enforcement.guard.js.map +0 -1
- package/dist/enforcement/reenroll-allowlist.js.map +0 -1
- package/dist/identity/auth-facts.controller.js.map +0 -1
- package/dist/identity/identity-actions.js.map +0 -1
- package/dist/identity/module-registry.bridge.js.map +0 -1
- package/dist/identity/permission-facts.adapter.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/migrations-dir.js.map +0 -1
- package/dist/module/shield-tokens.js.map +0 -1
- package/dist/module/shield.module.js.map +0 -1
- package/dist/request-auth/request-principal.js.map +0 -1
- package/dist/request-auth/session-facts-als.js.map +0 -1
- package/dist/request-auth/session-token-binder.js.map +0 -1
- package/dist/request-auth/tokens.js.map +0 -1
- package/dist/scope/closure-builder.js.map +0 -1
- package/dist/session/crypto-adapters.js.map +0 -1
- package/dist/session/index.js.map +0 -1
- package/dist/session/mariadb-session-record-store.js.map +0 -1
- 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;
|
|
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":"
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
143
|
-
permissionsVersion:
|
|
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
|
-
|
|
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;
|
|
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;
|
|
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 <= ?)
|