@astram/guard 0.5.1 → 0.6.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.map +1 -1
- package/dist/auth/account.controller.js +3 -0
- package/dist/auth/auth.controller.d.ts +90 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +245 -7
- package/dist/auth/auth.module.d.ts +21 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +28 -0
- package/dist/auth/dto.d.ts +32 -0
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/ports/membership-checker.d.ts +41 -0
- package/dist/auth/ports/membership-checker.d.ts.map +1 -0
- package/dist/auth/ports/membership-checker.js +32 -0
- package/dist/auth/ports/scope-subtree-resolver.d.ts +48 -0
- package/dist/auth/ports/scope-subtree-resolver.d.ts.map +1 -0
- package/dist/auth/ports/scope-subtree-resolver.js +38 -0
- package/dist/auth/tokens.d.ts +25 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +26 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +10 -1
- package/dist/authz/list-scope-sql.d.ts +125 -0
- package/dist/authz/list-scope-sql.d.ts.map +1 -0
- package/dist/authz/list-scope-sql.js +477 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +28 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +49 -0
- package/dist/authz/mariadb-list-scope-adapter.d.ts +61 -0
- package/dist/authz/mariadb-list-scope-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-list-scope-adapter.js +157 -0
- package/dist/authz/mariadb-policy-store.d.ts +118 -0
- package/dist/authz/mariadb-policy-store.d.ts.map +1 -0
- package/dist/authz/mariadb-policy-store.js +225 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts +7 -3
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +156 -13
- package/dist/authz/record-share-writer.d.ts +22 -0
- package/dist/authz/record-share-writer.d.ts.map +1 -1
- package/dist/authz/record-share-writer.js +41 -0
- package/dist/authz/wire-record-authorization.d.ts +35 -1
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +75 -0
- package/dist/identity/identity-actions.d.ts +7 -0
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/identity-actions.js +8 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -3
- package/dist/module/shield.module.d.ts +33 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +12 -0
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +12 -0
- package/dist/request-auth/session-token-binder.d.ts +35 -1
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +123 -8
- package/dist/scope/closure-builder.d.ts +75 -0
- package/dist/scope/closure-builder.d.ts.map +1 -0
- package/dist/scope/closure-builder.js +134 -0
- package/migrations/1710000000009-CreateRecordPermissionRule.cjs +56 -0
- package/migrations/1710000000014-CreateRecordShare.cjs +49 -0
- package/migrations/1710000000015-CreateScopeClosure.cjs +46 -0
- package/migrations/1710000000016-AlterCompanyParentFkRestrict.cjs +71 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* `@nestjs/core`, `reflect-metadata`, `rxjs` so the host owns one copy of each.
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.
|
|
20
|
-
exports.
|
|
21
|
-
exports.AccountController = exports.generateRecoveryCodes = void 0;
|
|
19
|
+
exports.SHIELD_RECORD_SHARE_PORT = exports.MariaDbRecordShareAdapter = exports.UnknownScopeFieldError = exports.translateListScope = exports.SHIELD_LIST_SCOPE_PORT = exports.MariaDbListScopeAdapter = exports.SHIELD_POLICY_STORE = exports.MariaDbPolicyStore = exports.SHIELD_RECORD_RULE_PORT = exports.MariaDbRecordPermissionRuleAdapter = exports.SHIELD_CLOCK = exports.SHIELD_REFRESH_HASHER = exports.SHIELD_TOKEN_GENERATOR = exports.systemClockProvider = exports.refreshHasherProvider = exports.cryptoTokenGeneratorProvider = exports.SystemClock = exports.Sha256RefreshTokenHasher = exports.CryptoTokenGenerator = exports.SHIELD_HOT_SESSION_STORE = exports.RedisHotSessionStore = exports.SHIELD_SESSION_RECORD_STORE = exports.MariaDbSessionRecordStore = exports.SHIELD_PASSWORD_HASHER = exports.argon2idHasherProvider = exports.Argon2idPasswordHasher = exports.stubDecisionAuthority = exports.GlobalEnforcementGuard = exports.NO_SESSION_FACTS = exports.SHIELD_SESSION_FACTS_ALS = exports.extractBearerToken = exports.principalFromClaims = exports.resolveRequestPrincipal = exports.SessionTokenBinder = exports.SHIELD_SESSION_TOKEN_BINDER = exports.CatalogGatabilityError = exports.CatalogReconciler = exports.OperationInventoryScanner = exports.normalizeClassification = exports.DEFAULT_CLASSIFIED_ACTION = exports.CLASSIFICATION_METADATA = exports.Classified = exports.Public = exports.SHIELD_DECISION_AUTHORITY = exports.ShieldModule = exports.actionsFor = exports.IDENTITY_ACTIONS = exports.MODULE_KEY = exports.buildIdentityModuleRegistry = exports.guardMigrationsDir = void 0;
|
|
20
|
+
exports.makeDefaultTotpVerifier = exports.AuthModule = exports.PersistenceModule = exports.MariaDbCredentialResolver = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.DEFAULT_REDIS_URL = exports.DEFAULT_DB_CONFIG = exports.assertRefreshHmacKey = exports.loadRefreshHmacKey = exports.loadSessionLifetimes = exports.loadRedisConfig = exports.loadDbConfig = exports.SCHEMA_SVC_POOL_HARD_CAP = exports.DEFAULT_POOL_BUDGET = exports.evaluatePoolBudget = exports.loadPoolBudget = exports.loadAppConfig = exports.APP_CONFIG = exports.assertPoolBudget = exports.BootSequence = exports.buildListScopeFn = exports.buildRecordAuthorization = exports.SHIELD_OPERATION_AUTHORITY_PORT = exports.ManifestOperationAuthorityAdapter = exports.GrantStoreUnavailableError = exports.InvalidGrantError = exports.GrantNotPermittedError = exports.SHIELD_CROSS_TENANT_GRANT_WRITER = exports.CrossTenantGrantWriter = exports.SHIELD_CROSS_TENANT_GRANT_PORT = exports.MariaDbCrossTenantGrantAdapter = exports.makeShareDeleteCascade = exports.ShareStoreUnavailableError = exports.ShareNotPermittedError = exports.SHIELD_RECORD_SHARE_WRITER = exports.RecordShareWriter = void 0;
|
|
21
|
+
exports.ScopeUnknownParentError = exports.ScopeDepthError = exports.ScopeCycleError = exports.buildClosureRows = exports.AccountController = exports.generateRecoveryCodes = exports.buildOtpauthUri = exports.generateTotpSecret = exports.PendingMfaStore = exports.AuthController = exports.SHIELD_SCOPE_SUBTREE_RESOLVER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.hmacSha1 = exports.resolveTotpVerifier = void 0;
|
|
22
22
|
// Framework authz migrations (shield_authz DDL — permission / role_permission /
|
|
23
23
|
// authz_permission_version). Absolute path a consumer's `@astram/migrate` setup merges in.
|
|
24
24
|
var migrations_dir_js_1 = require("./migrations-dir.js");
|
|
@@ -127,6 +127,30 @@ Object.defineProperty(exports, "SHIELD_CLOCK", { enumerable: true, get: function
|
|
|
127
127
|
var mariadb_record_rule_adapter_js_1 = require("./authz/mariadb-record-rule-adapter.js");
|
|
128
128
|
Object.defineProperty(exports, "MariaDbRecordPermissionRuleAdapter", { enumerable: true, get: function () { return mariadb_record_rule_adapter_js_1.MariaDbRecordPermissionRuleAdapter; } });
|
|
129
129
|
Object.defineProperty(exports, "SHIELD_RECORD_RULE_PORT", { enumerable: true, get: function () { return mariadb_record_rule_adapter_js_1.SHIELD_RECORD_RULE_PORT; } });
|
|
130
|
+
// The guided-policy AUTHOR store (ADR-0003 / design Task 0.3): the concrete
|
|
131
|
+
// `PolicyStore` (core admin port) over the SAME `record_permission_rule` table the
|
|
132
|
+
// rule adapter enforces — author-what-enforces. Persists a `GuidedPolicy`'s N
|
|
133
|
+
// AND-composed conditions as N rows sharing one `rpr_policy_uuid` group id (the read
|
|
134
|
+
// adapter AND-groups by it). `everyone` subject (v1 mapping); canonical operator
|
|
135
|
+
// names; parameterized SQL; no hard-coded UUIDs. NOT wired into buildRecordAuthorization
|
|
136
|
+
// here — the admin authoring surface (Phase 5) consumes it.
|
|
137
|
+
var mariadb_policy_store_js_1 = require("./authz/mariadb-policy-store.js");
|
|
138
|
+
Object.defineProperty(exports, "MariaDbPolicyStore", { enumerable: true, get: function () { return mariadb_policy_store_js_1.MariaDbPolicyStore; } });
|
|
139
|
+
Object.defineProperty(exports, "SHIELD_POLICY_STORE", { enumerable: true, get: function () { return mariadb_policy_store_js_1.SHIELD_POLICY_STORE; } });
|
|
140
|
+
// The LIST-SCOPE port adapter + the ScopeSpec→SQL translator (ADR-0003 / ADR-0005 list
|
|
141
|
+
// form / Task 2.1). The adapter ({@link MariaDbListScopeAdapter}, `ListScopePort`) projects
|
|
142
|
+
// the SAME `record_permission_rule` rows the rule adapter enforces into abstract allow/deny
|
|
143
|
+
// {@link ScopeCondition} AND-groups; the PURE translator ({@link translateListScope}) turns
|
|
144
|
+
// core's {@link ListScopeSpec} into a SAFE, parameterized WHERE fragment (every VALUE a `?`
|
|
145
|
+
// bound param; ONLY registry-derived columns interpolated; unknown field ⇒ throw; negative
|
|
146
|
+
// operators fail closed on NULL; deny-wins as `AND NOT (...)`). Phase 3 injects the fragment
|
|
147
|
+
// into the engine's `list()`. Store outage ⇒ AuthorizationUnavailableError (never unbounded).
|
|
148
|
+
var mariadb_list_scope_adapter_js_1 = require("./authz/mariadb-list-scope-adapter.js");
|
|
149
|
+
Object.defineProperty(exports, "MariaDbListScopeAdapter", { enumerable: true, get: function () { return mariadb_list_scope_adapter_js_1.MariaDbListScopeAdapter; } });
|
|
150
|
+
Object.defineProperty(exports, "SHIELD_LIST_SCOPE_PORT", { enumerable: true, get: function () { return mariadb_list_scope_adapter_js_1.SHIELD_LIST_SCOPE_PORT; } });
|
|
151
|
+
var list_scope_sql_js_1 = require("./authz/list-scope-sql.js");
|
|
152
|
+
Object.defineProperty(exports, "translateListScope", { enumerable: true, get: function () { return list_scope_sql_js_1.translateListScope; } });
|
|
153
|
+
Object.defineProperty(exports, "UnknownScopeFieldError", { enumerable: true, get: function () { return list_scope_sql_js_1.UnknownScopeFieldError; } });
|
|
130
154
|
// The per-record SHARE adapter (ADR-0003 / design §6): the concrete `RecordSharePort` over
|
|
131
155
|
// the `record_share` table. Answers `hasShare` (user- OR role-granted shares, exact-or-`*`
|
|
132
156
|
// action). No share ⇒ false; store outage ⇒ AuthorizationUnavailableError. Injected into
|
|
@@ -196,6 +220,13 @@ Object.defineProperty(exports, "SHIELD_OPERATION_AUTHORITY_PORT", { enumerable:
|
|
|
196
220
|
// enforcement-only path core-free.
|
|
197
221
|
var wire_record_authorization_js_1 = require("./authz/wire-record-authorization.js");
|
|
198
222
|
Object.defineProperty(exports, "buildRecordAuthorization", { enumerable: true, get: function () { return wire_record_authorization_js_1.buildRecordAuthorization; } });
|
|
223
|
+
// The LIST-FORM sibling (Task 2.2): the engine-facing `ListScopeFn` the module wiring hands
|
|
224
|
+
// to the engine's `list()` (Task 3.1). `buildListScopeFn` is a SEPARATE factory (Option A)
|
|
225
|
+
// that takes the SAME `RecordAuthorizationInputs` and constructs its `MariaDbListScopeAdapter`
|
|
226
|
+
// over the SAME un-overridable `inputs.pool` — the adapter is NOT a field of the inputs, so a
|
|
227
|
+
// consumer cannot inject a fail-open scope port. Fail-closed: a transient throw PROPAGATES.
|
|
228
|
+
var wire_record_authorization_js_2 = require("./authz/wire-record-authorization.js");
|
|
229
|
+
Object.defineProperty(exports, "buildListScopeFn", { enumerable: true, get: function () { return wire_record_authorization_js_2.buildListScopeFn; } });
|
|
199
230
|
// Boot infrastructure (ADR-0018 connection-pool budget assertion). `BootModule`
|
|
200
231
|
// itself is NOT exported — it is composed internally by `ShieldModule.forRoot()`.
|
|
201
232
|
var boot_sequence_js_1 = require("./boot/boot-sequence.js");
|
|
@@ -279,6 +310,8 @@ Object.defineProperty(exports, "resolveTotpVerifier", { enumerable: true, get: f
|
|
|
279
310
|
Object.defineProperty(exports, "hmacSha1", { enumerable: true, get: function () { return auth_module_js_2.hmacSha1; } });
|
|
280
311
|
var tokens_js_3 = require("./auth/tokens.js");
|
|
281
312
|
Object.defineProperty(exports, "SHIELD_SESSION_FACTS_PROJECTOR", { enumerable: true, get: function () { return tokens_js_3.SHIELD_SESSION_FACTS_PROJECTOR; } });
|
|
313
|
+
var tokens_js_4 = require("./auth/tokens.js");
|
|
314
|
+
Object.defineProperty(exports, "SHIELD_SCOPE_SUBTREE_RESOLVER", { enumerable: true, get: function () { return tokens_js_4.SHIELD_SCOPE_SUBTREE_RESOLVER; } });
|
|
282
315
|
// The framework-owned `/auth/*` HTTP controller (Team C) + its pending-MFA store.
|
|
283
316
|
// The controller CONSUMES SHIELD_AUTHENTICATOR / SHIELD_SESSION_AUTHORITY and the
|
|
284
317
|
// PendingMfaStore. It is EXPORTED so AuthModule (Team B) / the integration owner
|
|
@@ -301,3 +334,14 @@ Object.defineProperty(exports, "generateRecoveryCodes", { enumerable: true, get:
|
|
|
301
334
|
// later). Registered by AuthModule alongside AuthController under `auth: true`.
|
|
302
335
|
var account_controller_js_1 = require("./auth/account.controller.js");
|
|
303
336
|
Object.defineProperty(exports, "AccountController", { enumerable: true, get: function () { return account_controller_js_1.AccountController; } });
|
|
337
|
+
// The PURE scope-closure builder (ADR-0027 / Phase 1 Task 1.1 of the scoped-hierarchy
|
|
338
|
+
// tenancy program): turns a node forest's parent edges into the flat ancestor/descendant/
|
|
339
|
+
// depth rows the `shield_authz.scope_closure` table stores. No I/O — the Task 1.2
|
|
340
|
+
// reconciler is the one that calls this against real edges and writes the rows back.
|
|
341
|
+
// `ScopeCycleError` / `ScopeDepthError` / `ScopeUnknownParentError` are the fail-loud
|
|
342
|
+
// exceptions a caller (the reconciler) must handle as data-corruption signals, never swallow.
|
|
343
|
+
var closure_builder_js_1 = require("./scope/closure-builder.js");
|
|
344
|
+
Object.defineProperty(exports, "buildClosureRows", { enumerable: true, get: function () { return closure_builder_js_1.buildClosureRows; } });
|
|
345
|
+
Object.defineProperty(exports, "ScopeCycleError", { enumerable: true, get: function () { return closure_builder_js_1.ScopeCycleError; } });
|
|
346
|
+
Object.defineProperty(exports, "ScopeDepthError", { enumerable: true, get: function () { return closure_builder_js_1.ScopeDepthError; } });
|
|
347
|
+
Object.defineProperty(exports, "ScopeUnknownParentError", { enumerable: true, get: function () { return closure_builder_js_1.ScopeUnknownParentError; } });
|
|
@@ -11,6 +11,8 @@ import type { SessionSweeper } from '../auth/ports/session-sweeper.js';
|
|
|
11
11
|
import type { MfaDeviceStore } from '../auth/ports/mfa-device-store.js';
|
|
12
12
|
import type { AuditSink } from '../auth/ports/audit-sink.js';
|
|
13
13
|
import type { SessionFactsProjector } from '../auth/ports/session-facts-projector.js';
|
|
14
|
+
import type { ScopeSubtreeResolver } from '../auth/ports/scope-subtree-resolver.js';
|
|
15
|
+
import type { MembershipChecker } from '../auth/ports/membership-checker.js';
|
|
14
16
|
import type { CoreDecisionAuthority } from '../enforcement/core-seam.js';
|
|
15
17
|
import { type AppConfig } from '../config/app-config.js';
|
|
16
18
|
import { SHIELD_DECISION_AUTHORITY } from './shield-tokens.js';
|
|
@@ -156,6 +158,25 @@ export interface ShieldModuleOptions {
|
|
|
156
158
|
* `cachedFacts`. Only consulted when `auth: true`.
|
|
157
159
|
*/
|
|
158
160
|
readonly sessionFactsProjector?: SessionFactsProjector;
|
|
161
|
+
/**
|
|
162
|
+
* OPTIONAL active-scope-subtree resolver (Task 2.2, ADR-0027 / ADR-0024) — see
|
|
163
|
+
* {@link AuthModuleOptions.scopeSubtreeResolver}. Threaded straight through to
|
|
164
|
+
* {@link AuthModule.forRoot} (bound under {@link SHIELD_SCOPE_SUBTREE_RESOLVER}, or `null` when
|
|
165
|
+
* absent → the auth controller omits `scopeSubtreeNodeIds` and the per-request path falls back to
|
|
166
|
+
* the self-node `[tenantId]`, today's flat isolation). When supplied, login resolves the
|
|
167
|
+
* principal's closure subtree ONCE and caches it on the session binding so the per-request tenant
|
|
168
|
+
* gate reads the real subtree with zero DB. Only consulted when `auth: true`.
|
|
169
|
+
*/
|
|
170
|
+
readonly scopeSubtreeResolver?: ScopeSubtreeResolver;
|
|
171
|
+
/**
|
|
172
|
+
* OPTIONAL scope-switch membership checker (Task 5.2, ADR-0027 / ADR-0024) — see
|
|
173
|
+
* {@link AuthModuleOptions.membershipChecker}. Threaded straight through to
|
|
174
|
+
* {@link AuthModule.forRoot} (bound under {@link SHIELD_MEMBERSHIP_CHECKER}, or `null` when
|
|
175
|
+
* absent → `POST /auth/scope-switch` fails closed, denying every switch — cannot verify the
|
|
176
|
+
* target binding). When supplied, the scope-switch gate confirms the principal holds an active
|
|
177
|
+
* membership at the target node before re-binding the session. Only consulted when `auth: true`.
|
|
178
|
+
*/
|
|
179
|
+
readonly membershipChecker?: MembershipChecker;
|
|
159
180
|
}
|
|
160
181
|
/** Async variant of {@link ShieldModuleOptions} (DI-computed config). */
|
|
161
182
|
export interface ShieldModuleAsyncOptions {
|
|
@@ -248,6 +269,18 @@ export interface ShieldModuleAsyncOptions {
|
|
|
248
269
|
* options. Only consulted when `auth: true`.
|
|
249
270
|
*/
|
|
250
271
|
readonly sessionFactsProjector?: SessionFactsProjector;
|
|
272
|
+
/**
|
|
273
|
+
* OPTIONAL active-scope-subtree resolver — see {@link ShieldModuleOptions.scopeSubtreeResolver}.
|
|
274
|
+
* STATIC here (like the other seams): a registration-time collaborator threaded into the
|
|
275
|
+
* statically-composed {@link AuthModule} import. Only consulted when `auth: true`.
|
|
276
|
+
*/
|
|
277
|
+
readonly scopeSubtreeResolver?: ScopeSubtreeResolver;
|
|
278
|
+
/**
|
|
279
|
+
* OPTIONAL scope-switch membership checker — see {@link ShieldModuleOptions.membershipChecker}.
|
|
280
|
+
* STATIC here (like the other seams): a registration-time collaborator threaded into the
|
|
281
|
+
* statically-composed {@link AuthModule} import. Only consulted when `auth: true`.
|
|
282
|
+
*/
|
|
283
|
+
readonly membershipChecker?: MembershipChecker;
|
|
251
284
|
}
|
|
252
285
|
/**
|
|
253
286
|
* `ShieldModule` — the SINGLE registration seam (ADR-0023 §4).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shield.module.d.ts","sourceRoot":"","sources":["../../src/module/shield.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AAOnF,OAAO,KAAK,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAQvE,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,uCAAuC,CAAC;AACvF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAC3E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAC1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sCAAsC,CAAC;AAC9E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AACvE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AACtF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AAKzE,OAAO,EAAiB,KAAK,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAYxE,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAC/D,OAAO,EAAE,yBAAyB,EAAE,CAAC;AAIrC,YAAY,EAAE,yBAAyB,EAAE,MAAM,uCAAuC,CAAC;AAyFvF;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,qBAAqB,CAAC;IACnD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,yBAAyB,CAAC;IACzD;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACjD;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,eAAe,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAC7C,QAAQ,CAAC,eAAe,CAAC,EAAE,eAAe,CAAC;IAC3C,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IACnC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IAC/C,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,CAAC;IACzC;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,CAAC;IACzC;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,cAAc,CAAC;IACvC;;;;;;;;OAQG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;;OAIG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChD,0FAA0F;IAC1F,QAAQ,CAAC,wBAAwB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtD;;;;;;;;OAQG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"shield.module.d.ts","sourceRoot":"","sources":["../../src/module/shield.module.ts"],"names":[],"mappings":"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"}
|
|
@@ -197,6 +197,12 @@ let ShieldModule = ShieldModule_1 = class ShieldModule {
|
|
|
197
197
|
...(options.sessionFactsProjector
|
|
198
198
|
? { sessionFactsProjector: options.sessionFactsProjector }
|
|
199
199
|
: {}),
|
|
200
|
+
...(options.scopeSubtreeResolver
|
|
201
|
+
? { scopeSubtreeResolver: options.scopeSubtreeResolver }
|
|
202
|
+
: {}),
|
|
203
|
+
...(options.membershipChecker
|
|
204
|
+
? { membershipChecker: options.membershipChecker }
|
|
205
|
+
: {}),
|
|
200
206
|
})
|
|
201
207
|
: undefined;
|
|
202
208
|
return {
|
|
@@ -308,6 +314,12 @@ let ShieldModule = ShieldModule_1 = class ShieldModule {
|
|
|
308
314
|
...(options.sessionFactsProjector
|
|
309
315
|
? { sessionFactsProjector: options.sessionFactsProjector }
|
|
310
316
|
: {}),
|
|
317
|
+
...(options.scopeSubtreeResolver
|
|
318
|
+
? { scopeSubtreeResolver: options.scopeSubtreeResolver }
|
|
319
|
+
: {}),
|
|
320
|
+
...(options.membershipChecker
|
|
321
|
+
? { membershipChecker: options.membershipChecker }
|
|
322
|
+
: {}),
|
|
311
323
|
})
|
|
312
324
|
: undefined;
|
|
313
325
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-principal.d.ts","sourceRoot":"","sources":["../../src/request-auth/request-principal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAK1F,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,mBAAmB,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAK9E;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,oBAAoB,GAAG,SAAS,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"request-principal.d.ts","sourceRoot":"","sources":["../../src/request-auth/request-principal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AAK1F,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,mBAAmB,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAK9E;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,oBAAoB,GAAG,SAAS,GAAG,IAAI,CAiClF;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAC3C,mBAAmB,EAAE,OAAO,EAC5B,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAM3B;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,mBAAmB,EAAE,OAAO,EAC5B,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC;IAAE,SAAS,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,oBAAoB,CAAA;CAAE,GAAG,IAAI,CAAC,CAQxE"}
|
|
@@ -44,9 +44,21 @@ function principalFromClaims(claims) {
|
|
|
44
44
|
const roleIds = Array.isArray(claims.roleIds)
|
|
45
45
|
? claims.roleIds.filter((r) => typeof r === 'string')
|
|
46
46
|
: [];
|
|
47
|
+
// Scope (ADR-0027 / ADR-0024, Task 2.2) — the active scope node IS the bound tenant. The
|
|
48
|
+
// reachable subtree is the CLOSURE-RESOLVED set (self node + descendants) cached on the binding
|
|
49
|
+
// at LOGIN (`claims.scopeSubtreeNodeIds`), read here with ZERO DB — the parity guarantee: the
|
|
50
|
+
// closure query happened once at login, never per request. FAIL-SAFE + NON-WIDENING: when the
|
|
51
|
+
// cached subtree is ABSENT or EMPTY (a legacy binding, an unwired resolver, or a resolver that
|
|
52
|
+
// threw at login), fall back to just the self-node `[tenantId]` — today's flat self-tenant
|
|
53
|
+
// isolation, EXACTLY. A missing subtree can never widen a principal's reach.
|
|
54
|
+
const subtree = Array.isArray(claims.scopeSubtreeNodeIds) && claims.scopeSubtreeNodeIds.length > 0
|
|
55
|
+
? [...claims.scopeSubtreeNodeIds]
|
|
56
|
+
: [claims.tenantId];
|
|
47
57
|
return Object.freeze({
|
|
48
58
|
kind,
|
|
49
59
|
id: claims.id,
|
|
60
|
+
activeScopeNodeId: claims.tenantId,
|
|
61
|
+
scopeSubtreeNodeIds: Object.freeze(subtree),
|
|
50
62
|
tenantId: claims.tenantId,
|
|
51
63
|
roleIds: Object.freeze([...roleIds]),
|
|
52
64
|
isRoot: claims.isRoot === true,
|
|
@@ -65,6 +65,20 @@ export interface BoundPrincipalClaims {
|
|
|
65
65
|
* `cachedFacts` slot is treated as absent (see `resolve()`), never fabricated.
|
|
66
66
|
*/
|
|
67
67
|
readonly cachedFacts?: CoarseGrants;
|
|
68
|
+
/**
|
|
69
|
+
* OPTIONAL cached active-scope subtree (ADR-0027 / ADR-0024) — the active scope node + all its
|
|
70
|
+
* descendants, resolved ONCE at session establish from `shield_authz.scope_closure` and stored
|
|
71
|
+
* HERE so the per-request tenant gate reads it from the binding (zero per-request DB — the parity
|
|
72
|
+
* guarantee). The subtree query happens ONLY at login; `resolve()` returns this cached set and
|
|
73
|
+
* `principalFromClaims` reads it with NO DB call.
|
|
74
|
+
*
|
|
75
|
+
* ABSENT (undefined) → `principalFromClaims` falls back to `[tenantId]` (self-node, today's flat
|
|
76
|
+
* isolation) — the fail-safe, NON-WIDENING default for a legacy/old binding, an unwired resolver,
|
|
77
|
+
* or a resolver that threw at login. It is NEVER inferred; only a genuine login-time resolution
|
|
78
|
+
* sets it, and an EMPTY resolution is treated as absent (→ self-node) so a missing closure can
|
|
79
|
+
* never silently widen or empty a principal's reach.
|
|
80
|
+
*/
|
|
81
|
+
readonly scopeSubtreeNodeIds?: readonly string[];
|
|
68
82
|
}
|
|
69
83
|
export declare class SessionTokenBinder {
|
|
70
84
|
private readonly redis;
|
|
@@ -96,7 +110,27 @@ export declare class SessionTokenBinder {
|
|
|
96
110
|
* - `null` → STRIP `cachedFacts` (a projection that yielded nothing → the next
|
|
97
111
|
* request DB-reloads; fail-closed).
|
|
98
112
|
*/
|
|
99
|
-
rebindOnRotate(newAccessToken: string, sessionId: string, ttlMs: number, freshFacts?: CoarseGrants | null): Promise<boolean>;
|
|
113
|
+
rebindOnRotate(newAccessToken: string, sessionId: string, ttlMs: number, freshFacts?: CoarseGrants | null, freshSubtree?: readonly string[]): Promise<boolean>;
|
|
114
|
+
/**
|
|
115
|
+
* SCOPE-SWITCH re-bind (Task 5.2, ADR-0027 / ADR-0024). A principal is switching which
|
|
116
|
+
* active node it acts under; re-bind its CURRENT session (the same access token — no
|
|
117
|
+
* rotate, no new tokens) so the bound `tenantId` becomes `targetNodeId` (the new active
|
|
118
|
+
* scope node → `principalFromClaims` reads `activeScopeNodeId = tenantId`) and
|
|
119
|
+
* `scopeSubtreeNodeIds` becomes the NEW node's closure-resolved subtree. The old node's
|
|
120
|
+
* subtree/tenant is REPLACED — stale authority from the previous scope does NOT carry
|
|
121
|
+
* over (the security point of the switch).
|
|
122
|
+
*
|
|
123
|
+
* Rewrites BOTH the per-access-token binding AND the session-claims cushion (so a later
|
|
124
|
+
* refresh/rotate carries the NEW active scope forward), each preserving its remaining TTL
|
|
125
|
+
* (a scope-switch NEVER extends the session life). The `cachedFacts` slot is STRIPPED —
|
|
126
|
+
* the old node's coarse grants are stale under the new scope; the next request re-projects
|
|
127
|
+
* / DB-reloads (fail-closed, never a stale-grant carry-over). The caller resolves the
|
|
128
|
+
* subtree for the NEW node and passes it in.
|
|
129
|
+
*
|
|
130
|
+
* Returns `true` on a successful re-bind, `false` when the binding is gone (logged out /
|
|
131
|
+
* expired) or Redis is unavailable (the caller then denies the switch — fail-closed).
|
|
132
|
+
*/
|
|
133
|
+
rebindActiveScope(accessToken: string, sessionId: string, targetNodeId: string, subtree: readonly string[]): Promise<boolean>;
|
|
100
134
|
/**
|
|
101
135
|
* Resolve an access token to its bound claims, or `null` if the binding is
|
|
102
136
|
* absent/expired. Fail-closed: any Redis error → `null` (the caller denies), never
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-token-binder.d.ts","sourceRoot":"","sources":["../../src/request-auth/session-token-binder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAKjC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,oFAAoF;AACpF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,iEAAiE;IACjE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"session-token-binder.d.ts","sourceRoot":"","sources":["../../src/request-auth/session-token-binder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAKjC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,oFAAoF;AACpF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,iEAAiE;IACjE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,YAAY,CAAC;IACpC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAClD;AAmED,qBAAa,kBAAkB;IACjB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,KAAK;IAEzC;;;;;;;OAOG;IACG,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,oBAAoB,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ3F;;;;;;;;;;;;;;;;;OAiBG;IACG,cAAc,CAClB,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,UAAU,CAAC,EAAE,YAAY,GAAG,IAAI,EAChC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,GAC/B,OAAO,CAAC,OAAO,CAAC;IA2CnB;;;;;;;;;;;;;;;;;;OAkBG;IACG,iBAAiB,CACrB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB,OAAO,CAAC,OAAO,CAAC;IAmDnB;;;;;OAKG;IACG,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAwExE;;;;;;;;;;;;OAYG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA+BrD,gDAAgD;IAC1C,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQhD;;;;OAIG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAOtD"}
|
|
@@ -45,6 +45,30 @@ function parseCachedFacts(slot) {
|
|
|
45
45
|
grantedPermissionKeys: [...c.grantedPermissionKeys],
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Structurally validate the OPTIONAL `scopeSubtreeNodeIds` slot from the (untrusted) Redis
|
|
50
|
+
* payload. Returns a fresh string[] of the non-empty node ids, or `undefined` when the slot is
|
|
51
|
+
* absent, not an array, empty, or carries any non-string element (fail-safe → the per-request
|
|
52
|
+
* path falls back to the self-node; a junk slot can never widen or empty a principal's reach).
|
|
53
|
+
*/
|
|
54
|
+
function parseScopeSubtree(slot) {
|
|
55
|
+
if (!Array.isArray(slot) || slot.length === 0)
|
|
56
|
+
return undefined;
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const n of slot) {
|
|
59
|
+
if (typeof n !== 'string' || n.length === 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
out.push(n);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** Return the claims with any `scopeSubtreeNodeIds` slot removed (absent → self-node fallback). */
|
|
66
|
+
function stripScopeSubtree(claims) {
|
|
67
|
+
if (!('scopeSubtreeNodeIds' in claims))
|
|
68
|
+
return claims;
|
|
69
|
+
const { scopeSubtreeNodeIds: _drop, ...rest } = claims;
|
|
70
|
+
return rest;
|
|
71
|
+
}
|
|
48
72
|
/** Return the claims with any `cachedFacts` slot removed (absent → gate 5 DB-reloads). */
|
|
49
73
|
function stripCachedFacts(claims) {
|
|
50
74
|
if (!('cachedFacts' in claims))
|
|
@@ -100,7 +124,7 @@ class SessionTokenBinder {
|
|
|
100
124
|
* - `null` → STRIP `cachedFacts` (a projection that yielded nothing → the next
|
|
101
125
|
* request DB-reloads; fail-closed).
|
|
102
126
|
*/
|
|
103
|
-
async rebindOnRotate(newAccessToken, sessionId, ttlMs, freshFacts) {
|
|
127
|
+
async rebindOnRotate(newAccessToken, sessionId, ttlMs, freshFacts, freshSubtree) {
|
|
104
128
|
let raw;
|
|
105
129
|
try {
|
|
106
130
|
raw = await this.redis.get(sessClaimsKey(sessionId));
|
|
@@ -110,16 +134,28 @@ class SessionTokenBinder {
|
|
|
110
134
|
}
|
|
111
135
|
if (raw === null)
|
|
112
136
|
return false;
|
|
113
|
-
// Default: carry the existing payload forward byte-for-byte (facts unchanged).
|
|
137
|
+
// Default: carry the existing payload forward byte-for-byte (facts + subtree unchanged).
|
|
114
138
|
let payload = raw;
|
|
115
|
-
if (freshFacts !== undefined) {
|
|
116
|
-
// A refresh-time projection was supplied → rewrite
|
|
117
|
-
// carried-forward claims. A parse failure here is treated as "nothing to update"
|
|
118
|
-
//
|
|
119
|
-
//
|
|
139
|
+
if (freshFacts !== undefined || freshSubtree !== undefined) {
|
|
140
|
+
// A refresh-time projection and/or subtree was supplied → rewrite those slots on the
|
|
141
|
+
// carried-forward claims. A parse failure here is treated as "nothing to update" and we
|
|
142
|
+
// carry the original payload forward (fail-safe: never drop the whole binding on a
|
|
143
|
+
// transient JSON hiccup — a later request re-projects/DB-reloads).
|
|
120
144
|
try {
|
|
121
145
|
const parsed = JSON.parse(raw);
|
|
122
|
-
|
|
146
|
+
let next = parsed;
|
|
147
|
+
if (freshFacts !== undefined) {
|
|
148
|
+
next =
|
|
149
|
+
freshFacts === null ? stripCachedFacts(next) : { ...next, cachedFacts: freshFacts };
|
|
150
|
+
}
|
|
151
|
+
// The subtree (the stale-subtree-after-move fix): a non-empty fresh set REPLACES the
|
|
152
|
+
// carried-forward one (a re-parented session gets its correct, possibly-smaller subtree).
|
|
153
|
+
// `undefined` (no resolver / empty / threw at refresh) → carry the existing subtree
|
|
154
|
+
// forward (fail-safe, non-widening — never narrow to self-node on a transient resolver
|
|
155
|
+
// hiccup, never fail the refresh).
|
|
156
|
+
if (freshSubtree !== undefined && freshSubtree.length > 0) {
|
|
157
|
+
next = { ...next, scopeSubtreeNodeIds: [...freshSubtree] };
|
|
158
|
+
}
|
|
123
159
|
payload = JSON.stringify(next);
|
|
124
160
|
}
|
|
125
161
|
catch {
|
|
@@ -131,6 +167,77 @@ class SessionTokenBinder {
|
|
|
131
167
|
await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', Math.max(ttlMs * 4, ttlMs));
|
|
132
168
|
return true;
|
|
133
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* SCOPE-SWITCH re-bind (Task 5.2, ADR-0027 / ADR-0024). A principal is switching which
|
|
172
|
+
* active node it acts under; re-bind its CURRENT session (the same access token — no
|
|
173
|
+
* rotate, no new tokens) so the bound `tenantId` becomes `targetNodeId` (the new active
|
|
174
|
+
* scope node → `principalFromClaims` reads `activeScopeNodeId = tenantId`) and
|
|
175
|
+
* `scopeSubtreeNodeIds` becomes the NEW node's closure-resolved subtree. The old node's
|
|
176
|
+
* subtree/tenant is REPLACED — stale authority from the previous scope does NOT carry
|
|
177
|
+
* over (the security point of the switch).
|
|
178
|
+
*
|
|
179
|
+
* Rewrites BOTH the per-access-token binding AND the session-claims cushion (so a later
|
|
180
|
+
* refresh/rotate carries the NEW active scope forward), each preserving its remaining TTL
|
|
181
|
+
* (a scope-switch NEVER extends the session life). The `cachedFacts` slot is STRIPPED —
|
|
182
|
+
* the old node's coarse grants are stale under the new scope; the next request re-projects
|
|
183
|
+
* / DB-reloads (fail-closed, never a stale-grant carry-over). The caller resolves the
|
|
184
|
+
* subtree for the NEW node and passes it in.
|
|
185
|
+
*
|
|
186
|
+
* Returns `true` on a successful re-bind, `false` when the binding is gone (logged out /
|
|
187
|
+
* expired) or Redis is unavailable (the caller then denies the switch — fail-closed).
|
|
188
|
+
*/
|
|
189
|
+
async rebindActiveScope(accessToken, sessionId, targetNodeId, subtree) {
|
|
190
|
+
// Read the current claims (from the per-access-token entry) + confirm the session is
|
|
191
|
+
// still live (the session-claims entry exists). Any miss / Redis error → fail closed.
|
|
192
|
+
let rawAt;
|
|
193
|
+
let rawSess;
|
|
194
|
+
try {
|
|
195
|
+
rawAt = await this.redis.get(atKey(accessToken));
|
|
196
|
+
rawSess = await this.redis.get(sessClaimsKey(sessionId));
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false; // Redis unavailable → fail closed (no switch).
|
|
200
|
+
}
|
|
201
|
+
if (rawAt === null || rawSess === null)
|
|
202
|
+
return false; // token/session gone → fail closed.
|
|
203
|
+
let current;
|
|
204
|
+
try {
|
|
205
|
+
current = JSON.parse(rawAt);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return false; // malformed → fail closed (a later request re-denies anyway).
|
|
209
|
+
}
|
|
210
|
+
// Build the re-scoped claims: new active node (tenantId) + new subtree, facts stripped.
|
|
211
|
+
// An empty subtree is stored as absent (→ per-request self-node fallback, non-widening) —
|
|
212
|
+
// but the switch caller always passes at least `[targetNodeId]`, so this is defensive.
|
|
213
|
+
const next = stripCachedFacts({
|
|
214
|
+
...stripScopeSubtree(current),
|
|
215
|
+
tenantId: targetNodeId,
|
|
216
|
+
sessionId,
|
|
217
|
+
...(subtree.length > 0 ? { scopeSubtreeNodeIds: [...subtree] } : {}),
|
|
218
|
+
});
|
|
219
|
+
const payload = JSON.stringify(next);
|
|
220
|
+
// Preserve each key's remaining TTL — a scope-switch must never extend the session.
|
|
221
|
+
let atTtl;
|
|
222
|
+
let sessTtl;
|
|
223
|
+
try {
|
|
224
|
+
atTtl = await this.redis.pttl(atKey(accessToken));
|
|
225
|
+
sessTtl = await this.redis.pttl(sessClaimsKey(sessionId));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
if (atTtl <= 0 || sessTtl <= 0)
|
|
231
|
+
return false; // expired between reads → fail closed.
|
|
232
|
+
try {
|
|
233
|
+
await this.redis.set(atKey(accessToken), payload, 'PX', atTtl);
|
|
234
|
+
await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', sessTtl);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
134
241
|
/**
|
|
135
242
|
* Resolve an access token to its bound claims, or `null` if the binding is
|
|
136
243
|
* absent/expired. Fail-closed: any Redis error → `null` (the caller denies), never
|
|
@@ -171,6 +278,14 @@ class SessionTokenBinder {
|
|
|
171
278
|
parsed = cachedFacts === undefined
|
|
172
279
|
? stripCachedFacts(parsed)
|
|
173
280
|
: { ...parsed, cachedFacts };
|
|
281
|
+
// Normalize the OPTIONAL scope-subtree slot (ADR-0027 / Task 2.2): an absent, empty, or
|
|
282
|
+
// malformed slot is scrubbed to ABSENT (→ `principalFromClaims` falls back to the self-node,
|
|
283
|
+
// non-widening); a well-formed non-empty array is the validated cached subtree. Never
|
|
284
|
+
// fabricated — rebuild the object so a junk slot cannot ride through into a Principal.
|
|
285
|
+
const scopeSubtreeNodeIds = parseScopeSubtree(parsed.scopeSubtreeNodeIds);
|
|
286
|
+
parsed = scopeSubtreeNodeIds === undefined
|
|
287
|
+
? stripScopeSubtree(parsed)
|
|
288
|
+
: { ...parsed, scopeSubtreeNodeIds };
|
|
174
289
|
// SESSION-LIVENESS GATE: the per-token binding alone is not enough — a logout
|
|
175
290
|
// revokes the session but cannot reach every outstanding access-token key. So we
|
|
176
291
|
// ALSO require the session-claims entry (which `unbindSession` deletes on logout)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildClosureRows` — the PURE, data-foundation half of Task 1.1 (Phase 1 of the
|
|
3
|
+
* scoped-hierarchy tenancy program). Turns a node forest's parent edges into the flat
|
|
4
|
+
* ancestor/descendant/depth rows the `shield_authz.scope_closure` table stores, so any
|
|
5
|
+
* subtree query (`WHERE ancestor_uuid = N`) or ancestry query (`WHERE descendant_uuid = N`)
|
|
6
|
+
* is a flat, indexed lookup — NEVER a recursive walk on any request-serving query path.
|
|
7
|
+
*
|
|
8
|
+
* This module does NO I/O. The Task 1.2 reconciler is the one that calls this against real
|
|
9
|
+
* node edges (read from the DB) and writes the resulting rows back to `scope_closure`; this
|
|
10
|
+
* file only computes the correct row set from an in-memory edge list.
|
|
11
|
+
*
|
|
12
|
+
* ── SEMANTICS ───────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* • EVERY node gets a self-row `{ancestor: n, descendant: n, depth: 0}` — including a
|
|
14
|
+
* root (parent === null) and including a node with NO children.
|
|
15
|
+
* • For each node, walking UP its parent chain, every ancestor `a` at chain-distance `d`
|
|
16
|
+
* (1-based: the immediate parent is depth 1, the grandparent depth 2, ...) emits
|
|
17
|
+
* `{ancestor: a, descendant: n, depth: d}`.
|
|
18
|
+
* • A FLAT set (every node's parent is `null`) therefore degenerates to SELF-ROWS ONLY —
|
|
19
|
+
* this is the non-breaking case: a flat deployment (no hierarchy configured yet) is
|
|
20
|
+
* just a depth-1 forest of roots, and every existing single-tenant scope check
|
|
21
|
+
* (`ancestor_uuid = descendant_uuid = tenant`) continues to hold.
|
|
22
|
+
* • A FOREST (multiple independent root trees) computes each branch independently — no
|
|
23
|
+
* cross-tree rows are ever emitted.
|
|
24
|
+
*
|
|
25
|
+
* ── FAILURE MODES (all fail LOUD — a closure computed over bad data must never silently
|
|
26
|
+
* produce a wrong, over- or under-scoped row set; that would be a tenant-isolation bug) ─
|
|
27
|
+
* • CYCLE (a node reachable from itself via parent edges, direct `A→A` or indirect
|
|
28
|
+
* `A→B→A`): throws {@link ScopeCycleError}. Detected by tracking the in-progress walk
|
|
29
|
+
* set per node — if the walk revisits a node already on the CURRENT path, it's a cycle.
|
|
30
|
+
* • DEPTH EXCEEDED (a parent chain longer than `opts.maxDepth`, default 32 — a sane bound
|
|
31
|
+
* for any real org hierarchy): throws {@link ScopeDepthError}. Guards against a
|
|
32
|
+
* pathological or malformed edge set turning this into an unbounded walk.
|
|
33
|
+
* • UNKNOWN PARENT (a node's `parent` id doesn't reference any node in the SAME edge set):
|
|
34
|
+
* throws a plain `Error` — an orphaned edge is a data error (e.g. the parent was deleted
|
|
35
|
+
* without re-parenting children), never a silent skip that would misplace a subtree.
|
|
36
|
+
* • Empty input → empty output (no throw) — the vacuous case.
|
|
37
|
+
*
|
|
38
|
+
* ── DETERMINISM: output order is not semantically meaningful (the caller writes rows to a
|
|
39
|
+
* PK-indexed table), but rows are sorted (ancestor, then descendant, then depth) so tests
|
|
40
|
+
* and diffs are stable.
|
|
41
|
+
*
|
|
42
|
+
* ── COMPLEXITY: O(nodes × depth) — for each node, one iterative (non-recursive) walk up its
|
|
43
|
+
* parent chain, bounded by `maxDepth`. No recursion, so no call-stack depth risk either.
|
|
44
|
+
*/
|
|
45
|
+
/** One parent edge in the node forest: `parent === null` marks a root. */
|
|
46
|
+
export interface ScopeEdge {
|
|
47
|
+
readonly node: string;
|
|
48
|
+
readonly parent: string | null;
|
|
49
|
+
}
|
|
50
|
+
/** One row of the computed transitive closure. */
|
|
51
|
+
export interface ClosureRow {
|
|
52
|
+
readonly ancestor: string;
|
|
53
|
+
readonly descendant: string;
|
|
54
|
+
readonly depth: number;
|
|
55
|
+
}
|
|
56
|
+
/** A node's parent chain revisits a node already on the walk — the edge set has a cycle. */
|
|
57
|
+
export declare class ScopeCycleError extends Error {
|
|
58
|
+
constructor(node: string);
|
|
59
|
+
}
|
|
60
|
+
/** A node's parent chain exceeds the configured (or default) maximum depth. */
|
|
61
|
+
export declare class ScopeDepthError extends Error {
|
|
62
|
+
constructor(node: string, maxDepth: number);
|
|
63
|
+
}
|
|
64
|
+
/** A node's `parent` id does not reference any node present in the same edge set. */
|
|
65
|
+
export declare class ScopeUnknownParentError extends Error {
|
|
66
|
+
constructor(node: string, parent: string);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Compute the transitive closure of a node forest from its parent edges.
|
|
70
|
+
* Pure — no I/O. See the file header for full semantics and failure modes.
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildClosureRows(edges: readonly ScopeEdge[], opts?: {
|
|
73
|
+
maxDepth?: number;
|
|
74
|
+
}): ClosureRow[];
|
|
75
|
+
//# sourceMappingURL=closure-builder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"closure-builder.d.ts","sourceRoot":"","sources":["../../src/scope/closure-builder.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,0EAA0E;AAC1E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,kDAAkD;AAClD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,4FAA4F;AAC5F,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,IAAI,EAAE,MAAM;CAQzB;AAED,+EAA+E;AAC/E,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAS3C;AAED,qFAAqF;AACrF,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CASzC;AAKD;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,SAAS,SAAS,EAAE,EAC3B,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3B,UAAU,EAAE,CA2Dd"}
|