@astram/guard 0.19.0 → 0.20.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 +3 -1
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +79 -6
- package/dist/auth/admin-signin-controls.d.ts +80 -0
- package/dist/auth/admin-signin-controls.d.ts.map +1 -0
- package/dist/auth/admin-signin-controls.js +91 -0
- package/dist/auth/auth-audit.d.ts +59 -0
- package/dist/auth/auth-audit.d.ts.map +1 -0
- package/dist/auth/auth-audit.js +93 -0
- package/dist/auth/auth.controller.d.ts +53 -6
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +330 -44
- package/dist/auth/auth.module.d.ts +49 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +149 -4
- package/dist/auth/captcha-gate.d.ts +103 -0
- package/dist/auth/captcha-gate.d.ts.map +1 -0
- package/dist/auth/captcha-gate.js +245 -0
- package/dist/auth/dto.d.ts +22 -1
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/login-name-count-boot.d.ts +24 -0
- package/dist/auth/login-name-count-boot.d.ts.map +1 -0
- package/dist/auth/login-name-count-boot.js +47 -0
- package/dist/auth/logout-proof.d.ts +55 -0
- package/dist/auth/logout-proof.d.ts.map +1 -0
- package/dist/auth/logout-proof.js +127 -0
- package/dist/auth/pending-mfa-store.d.ts +5 -0
- package/dist/auth/pending-mfa-store.d.ts.map +1 -1
- package/dist/auth/pending-mfa-store.js +2 -0
- package/dist/auth/ports/audit-sink.d.ts +5 -2
- package/dist/auth/ports/audit-sink.d.ts.map +1 -1
- package/dist/auth/ports/mfa-device-store.d.ts +7 -0
- package/dist/auth/ports/mfa-device-store.d.ts.map +1 -1
- package/dist/auth/profile-resolver.d.ts +6 -0
- package/dist/auth/profile-resolver.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.d.ts +6 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.js +14 -0
- package/dist/auth/tokens.d.ts +59 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +61 -1
- package/dist/authentication/credential-provisioner.d.ts +7 -0
- package/dist/authentication/credential-provisioner.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-provisioner.d.ts +1 -0
- package/dist/authentication/mariadb-credential-provisioner.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-provisioner.js +7 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +23 -2
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +45 -1
- package/dist/authentication/mariadb-mfa-device-store.d.ts +7 -0
- package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-mfa-device-store.js +32 -0
- package/dist/authentication/mariadb-mfa-requirement-source.d.ts +55 -0
- package/dist/authentication/mariadb-mfa-requirement-source.d.ts.map +1 -0
- package/dist/authentication/mariadb-mfa-requirement-source.js +164 -0
- package/dist/authentication/mariadb-mfa-resetter.d.ts +42 -0
- package/dist/authentication/mariadb-mfa-resetter.d.ts.map +1 -0
- package/dist/authentication/mariadb-mfa-resetter.js +59 -0
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +5 -0
- package/dist/authentication/sign-in-with.d.ts +14 -0
- package/dist/authentication/sign-in-with.d.ts.map +1 -0
- package/dist/authentication/sign-in-with.js +36 -0
- package/dist/authz/core-access.d.ts +11 -0
- package/dist/authz/core-access.d.ts.map +1 -0
- package/dist/authz/core-access.js +14 -0
- package/dist/authz/list-scope-sql.d.ts.map +1 -1
- package/dist/authz/list-scope-sql.js +15 -5
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +21 -3
- package/dist/captcha/captcha.providers.d.ts +18 -0
- package/dist/captcha/captcha.providers.d.ts.map +1 -0
- package/dist/captcha/captcha.providers.js +78 -0
- package/dist/captcha/recaptcha-verifier.d.ts +20 -0
- package/dist/captcha/recaptcha-verifier.d.ts.map +1 -0
- package/dist/captcha/recaptcha-verifier.js +48 -0
- package/dist/captcha/siteverify.d.ts +47 -0
- package/dist/captcha/siteverify.d.ts.map +1 -0
- package/dist/captcha/siteverify.js +98 -0
- package/dist/captcha/turnstile-verifier.d.ts +18 -0
- package/dist/captcha/turnstile-verifier.d.ts.map +1 -0
- package/dist/captcha/turnstile-verifier.js +41 -0
- package/dist/config/captcha-config.d.ts +47 -0
- package/dist/config/captcha-config.d.ts.map +1 -0
- package/dist/config/captcha-config.js +163 -0
- package/dist/config/reserved-principal-ids.d.ts +15 -0
- package/dist/config/reserved-principal-ids.d.ts.map +1 -1
- package/dist/config/reserved-principal-ids.js +20 -3
- package/dist/enforcement/global-enforcement.guard.d.ts +9 -1
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +52 -10
- package/dist/enforcement/reenroll-allowlist.d.ts +5 -0
- package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -1
- package/dist/enforcement/reenroll-allowlist.js +5 -0
- package/dist/identity/auth-facts.controller.d.ts +31 -4
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/auth-facts.controller.js +46 -11
- package/dist/identity/identity-actions.d.ts +2 -2
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.js +3 -6
- package/dist/identity/permission-facts.adapter.d.ts +22 -0
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +26 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -10
- package/dist/internal.d.ts +27 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +36 -1
- package/dist/module/shield.module.d.ts +27 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +10 -0
- package/dist/request-auth/request-principal.d.ts +8 -1
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +11 -4
- package/dist/request-auth/session-token-binder.d.ts +82 -0
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +203 -1
- package/dist/session/mfa-requirement-service.d.ts +168 -0
- package/dist/session/mfa-requirement-service.d.ts.map +1 -0
- package/dist/session/mfa-requirement-service.js +461 -0
- package/dist/session/session-restriction-store.d.ts +36 -0
- package/dist/session/session-restriction-store.d.ts.map +1 -1
- package/dist/session/session-restriction-store.js +57 -6
- package/dist/session/session-restriction.d.ts +22 -0
- package/dist/session/session-restriction.d.ts.map +1 -0
- package/dist/session/session-restriction.js +23 -0
- package/package.json +5 -5
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MfaRequirementService = exports.MfaRequirementUnavailableError = void 0;
|
|
4
|
+
exports.currentVersionReader = currentVersionReader;
|
|
5
|
+
/**
|
|
6
|
+
* MfaRequirementService — mandatory 2FA in the guard (auth capabilities R2.3–R2.5, R2.7, D-2, D-11).
|
|
7
|
+
* One evaluation (core `evaluateMfaRequirement`, B-2) over the guard's `MfaRequirementSource` (B-1),
|
|
8
|
+
* used at four points:
|
|
9
|
+
*
|
|
10
|
+
* 1. SIGN-IN ({@link atLogin}, a lifecycle event: DB reads allowed). The result is bound on the
|
|
11
|
+
* session: `'required'` ⇒ `restriction: 'mfa_enrollment'` (the controller also marks the durable
|
|
12
|
+
* column), `'grace'` ⇒ `mfaEnrollmentDeadlineMs`; always the version stamp the grants were read
|
|
13
|
+
* at. Any failure throws; the controller revokes the session and answers 503 (fail closed).
|
|
14
|
+
* 2. THE REQUEST PATH ({@link refresh}), on gated AND self-authenticating routes. It re-evaluates
|
|
15
|
+
* ONLY when the tenant's current permission version is newer than the session's stamp (or there
|
|
16
|
+
* is no stamp yet); otherwise it returns at once with NO DB query (N1, Shield #12, ADR-0035).
|
|
17
|
+
* Gated routes pass the version (and the reloaded grants) gate 5 already has; self-authenticating
|
|
18
|
+
* routes read the version from Redis ({@link currentVersionReader}: a hit is Redis only, a miss is
|
|
19
|
+
* the one DB fill gate 5 also does, a Redis error denies). A transition writes the durable column
|
|
20
|
+
* (a lifecycle write), then the session-claims entry by compare-and-set
|
|
21
|
+
* (`SessionTokenBinder.updateMfaRequirement`). Any failure throws
|
|
22
|
+
* {@link MfaRequirementUnavailableError} (503) and advances nothing, so the next request retries.
|
|
23
|
+
* 3. DEVICE CONFIRM ({@link onFactorConfirmed}): lifts the restriction on the current session (Redis
|
|
24
|
+
* claims and the durable column) and deletes the grace clock (AC-7, AC-10).
|
|
25
|
+
* 4. LAST-DEVICE REMOVAL ({@link requiresFactor}, R2.7): whether any policy applies (no clock write).
|
|
26
|
+
*
|
|
27
|
+
* The subject (decision (2) of slice D): the node chain comes from the session's ACTIVE node (scope
|
|
28
|
+
* closure); roles and permission keys from the grants (gate 5's reload, or the session-facts
|
|
29
|
+
* projector, the same `loadCoarseGrants` the decision authority uses). Root accounts are subject
|
|
30
|
+
* only to platform-floor policies (D-11, in the core rule).
|
|
31
|
+
*
|
|
32
|
+
* Constructed only when the policy feature is wired (`SHIELD_MFA_POLICY_FEATURE`); without it the
|
|
33
|
+
* guard behaves exactly as before.
|
|
34
|
+
*/
|
|
35
|
+
const common_1 = require("@nestjs/common");
|
|
36
|
+
const core_1 = require("@astram/core");
|
|
37
|
+
const session_token_binder_js_1 = require("../request-auth/session-token-binder.js");
|
|
38
|
+
/** The fail-closed answer when the requirement cannot be evaluated or recorded (503). */
|
|
39
|
+
class MfaRequirementUnavailableError extends common_1.HttpException {
|
|
40
|
+
constructor() {
|
|
41
|
+
super({ error: 'authentication_unavailable' }, common_1.HttpStatus.SERVICE_UNAVAILABLE);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.MfaRequirementUnavailableError = MfaRequirementUnavailableError;
|
|
45
|
+
const STATUS_RANK = {
|
|
46
|
+
required: 3,
|
|
47
|
+
grace: 2,
|
|
48
|
+
not_required: 1,
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The tenant's current permission version for the self-authenticating routes, read like gate 5
|
|
52
|
+
* reads it (ADR-0035): a Redis hit answers alone; a miss is filled once from the source of truth and
|
|
53
|
+
* written back (best-effort); a Redis ERROR throws {@link MfaRequirementUnavailableError} (deny,
|
|
54
|
+
* never served from the DB).
|
|
55
|
+
*/
|
|
56
|
+
function currentVersionReader(cache, fill) {
|
|
57
|
+
return async (tenantId) => {
|
|
58
|
+
let cached;
|
|
59
|
+
try {
|
|
60
|
+
cached = await cache.get(tenantId);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new MfaRequirementUnavailableError();
|
|
64
|
+
}
|
|
65
|
+
if (cached !== null)
|
|
66
|
+
return cached;
|
|
67
|
+
let version;
|
|
68
|
+
try {
|
|
69
|
+
version = await fill(tenantId);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new MfaRequirementUnavailableError();
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await cache.raise(tenantId, version);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Best-effort: the answer already came from the source of truth.
|
|
79
|
+
}
|
|
80
|
+
return version;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** The session state an evaluation asks for, at `version`. */
|
|
84
|
+
function stateFrom(req, version) {
|
|
85
|
+
if (req.status === 'required') {
|
|
86
|
+
return { restriction: 'mfa_enrollment', mfaRequirementVersion: version };
|
|
87
|
+
}
|
|
88
|
+
if (req.status === 'grace') {
|
|
89
|
+
return { mfaEnrollmentDeadlineMs: req.deadlineMs, mfaRequirementVersion: version };
|
|
90
|
+
}
|
|
91
|
+
return { mfaRequirementVersion: version };
|
|
92
|
+
}
|
|
93
|
+
/** A valid version: a non-negative safe integer. */
|
|
94
|
+
function isVersion(v) {
|
|
95
|
+
return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
96
|
+
}
|
|
97
|
+
class MfaRequirementService {
|
|
98
|
+
deps;
|
|
99
|
+
now;
|
|
100
|
+
constructor(deps) {
|
|
101
|
+
this.deps = deps;
|
|
102
|
+
this.now = deps.now ?? Date.now;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Evaluate the requirement and persist the clock the rule asks for (`start` keeps the EARLIER
|
|
106
|
+
* stored value, R2.4; `clear` deletes it). Throws on any store error (the caller fails closed).
|
|
107
|
+
*/
|
|
108
|
+
async evaluateFor(subject) {
|
|
109
|
+
const { source } = this.deps;
|
|
110
|
+
const nodeIds = await source.scopeChainOf(subject.tenantId);
|
|
111
|
+
const [policies, hasConfirmedFactor, requiredSinceMs] = await Promise.all([
|
|
112
|
+
source.loadPolicies(nodeIds),
|
|
113
|
+
source.hasConfirmedFactor(subject.principalId),
|
|
114
|
+
source.getRequiredSince(subject.principalId),
|
|
115
|
+
]);
|
|
116
|
+
const nowMs = this.now();
|
|
117
|
+
const input = {
|
|
118
|
+
policies,
|
|
119
|
+
subject: {
|
|
120
|
+
isRoot: subject.isRoot === true,
|
|
121
|
+
nodeIds,
|
|
122
|
+
roleIds: subject.roleIds,
|
|
123
|
+
permissionKeys: subject.permissionKeys,
|
|
124
|
+
},
|
|
125
|
+
hasConfirmedFactor,
|
|
126
|
+
requiredSinceMs,
|
|
127
|
+
nowMs,
|
|
128
|
+
};
|
|
129
|
+
let req = (0, core_1.evaluateMfaRequirement)(input);
|
|
130
|
+
if (req.clock === 'start' && (req.status === 'grace' || req.status === 'required')) {
|
|
131
|
+
const stored = await source.setRequiredSince(subject.principalId, req.requiredSinceMs);
|
|
132
|
+
// A concurrent first write kept an earlier clock: judge by that one.
|
|
133
|
+
if (stored !== req.requiredSinceMs) {
|
|
134
|
+
req = (0, core_1.evaluateMfaRequirement)({ ...input, requiredSinceMs: stored });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else if (req.clock === 'clear') {
|
|
138
|
+
await source.clearRequiredSince(subject.principalId);
|
|
139
|
+
}
|
|
140
|
+
return req;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The admin users list's `mfaStatus` for a page (auth capabilities R11.3, AC-17). READ ONLY: no
|
|
144
|
+
* clock is started or cleared (that stays a sign-in / request-path lifecycle write). Batched:
|
|
145
|
+
* one confirmed-factor read for the page; for the rest one scope-chain read, one policy read, and
|
|
146
|
+
* (only when a policy exists) one clock read. Grants are loaded per (user, node) ONLY where a
|
|
147
|
+
* `role` or `permission` policy could apply at that node (the documented per-row cost); `tenant`
|
|
148
|
+
* and platform policies need none. A confirmed device ⇒ `enrolled` whatever the policies; else the
|
|
149
|
+
* strictest answer over the user's active memberships (no membership ⇒ the platform floor only).
|
|
150
|
+
* Throws on any store error (the caller fails closed).
|
|
151
|
+
*/
|
|
152
|
+
async statusesFor(subjects) {
|
|
153
|
+
const { source } = this.deps;
|
|
154
|
+
const batch = this.deps.batch;
|
|
155
|
+
if (!batch)
|
|
156
|
+
throw new MfaRequirementUnavailableError();
|
|
157
|
+
const out = new Map();
|
|
158
|
+
const ids = [...new Set(subjects.map((s) => s.principalId).filter(isNonEmpty))];
|
|
159
|
+
if (ids.length === 0)
|
|
160
|
+
return out;
|
|
161
|
+
const enrolled = await batch.confirmedFactorsOf(ids);
|
|
162
|
+
const pending = subjects.filter((s) => isNonEmpty(s.principalId) && !enrolled.has(s.principalId));
|
|
163
|
+
for (const id of ids)
|
|
164
|
+
if (enrolled.has(id))
|
|
165
|
+
out.set(id, 'enrolled');
|
|
166
|
+
if (pending.length === 0)
|
|
167
|
+
return out;
|
|
168
|
+
const nodes = [...new Set(pending.flatMap((s) => [...(s.nodeIds ?? [])]).filter(isNonEmpty))];
|
|
169
|
+
const chains = nodes.length > 0 ? await batch.scopeChainsOf(nodes) : new Map();
|
|
170
|
+
const chainOf = (node) => chains.get(node) ?? [node];
|
|
171
|
+
const allChainNodes = [...new Set(nodes.flatMap((n) => [...chainOf(n)]))];
|
|
172
|
+
const policies = await source.loadPolicies(allChainNodes);
|
|
173
|
+
if (policies.length === 0) {
|
|
174
|
+
for (const s of pending)
|
|
175
|
+
out.set(s.principalId, 'not_required');
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
const pendingIds = [...new Set(pending.map((s) => s.principalId))];
|
|
179
|
+
const clocks = await batch.requiredSinceOf(pendingIds);
|
|
180
|
+
const nowMs = this.now();
|
|
181
|
+
for (const s of pending) {
|
|
182
|
+
const contexts = s.nodeIds.length > 0
|
|
183
|
+
? [...new Set(s.nodeIds.filter(isNonEmpty))].map((n) => ({ node: n, chain: chainOf(n) }))
|
|
184
|
+
: [{ node: null, chain: [] }];
|
|
185
|
+
let worst = 'not_required';
|
|
186
|
+
for (const ctx of contexts) {
|
|
187
|
+
const inChain = new Set(ctx.chain);
|
|
188
|
+
const candidates = policies.filter((p) => p.platform === true ||
|
|
189
|
+
(s.isRoot !== true && p.scopeNodeId !== null && inChain.has(p.scopeNodeId)));
|
|
190
|
+
if (candidates.length === 0)
|
|
191
|
+
continue;
|
|
192
|
+
const needsGrants = ctx.node !== null && candidates.some((p) => p.kind !== 'tenant');
|
|
193
|
+
const grants = needsGrants ? await this.grantsOf(s.principalId, ctx.node) : null;
|
|
194
|
+
const req = (0, core_1.evaluateMfaRequirement)({
|
|
195
|
+
policies: candidates,
|
|
196
|
+
subject: {
|
|
197
|
+
isRoot: s.isRoot === true,
|
|
198
|
+
nodeIds: ctx.chain,
|
|
199
|
+
roleIds: grants ? ((0, session_token_binder_js_1.liveRoleIds)(grants) ?? []) : [],
|
|
200
|
+
permissionKeys: grants ? grants.grantedPermissionKeys : [],
|
|
201
|
+
},
|
|
202
|
+
hasConfirmedFactor: false,
|
|
203
|
+
requiredSinceMs: clocks.get(s.principalId) ?? null,
|
|
204
|
+
nowMs,
|
|
205
|
+
});
|
|
206
|
+
const status = req.status === 'enrolled' ? 'not_required' : req.status;
|
|
207
|
+
if (STATUS_RANK[status] > STATUS_RANK[worst])
|
|
208
|
+
worst = status;
|
|
209
|
+
if (worst === 'required')
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
out.set(s.principalId, worst);
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The state to bind at sign-in. `projected` is the login's own projection (reused: no second
|
|
218
|
+
* grants read); absent or `null` ⇒ the grants are loaded here. Throws on any failure.
|
|
219
|
+
*/
|
|
220
|
+
async atLogin(principal, projected) {
|
|
221
|
+
const grants = projected ?? (await this.grantsOf(principal.id, principal.tenantId));
|
|
222
|
+
if (!isVersion(grants.permissionsVersion))
|
|
223
|
+
throw new MfaRequirementUnavailableError();
|
|
224
|
+
const req = await this.evaluateFor({
|
|
225
|
+
principalId: principal.id,
|
|
226
|
+
isRoot: principal.isRoot === true,
|
|
227
|
+
tenantId: principal.tenantId,
|
|
228
|
+
roleIds: (0, session_token_binder_js_1.liveRoleIds)(grants) ?? [...principal.roleIds],
|
|
229
|
+
permissionKeys: grants.grantedPermissionKeys,
|
|
230
|
+
});
|
|
231
|
+
if (req.status === 'not_required' && req.clock === 'keep') {
|
|
232
|
+
await this.clearClockIfRequiredNowhere(principal);
|
|
233
|
+
}
|
|
234
|
+
return stateFrom(req, grants.permissionsVersion);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Review item E2: sign-in is a lifecycle event, so a stored clock that the CURRENT node does not
|
|
238
|
+
* need is checked against every active membership of the user (the users-page evaluation,
|
|
239
|
+
* {@link statusesFor}, plus the platform floor). Required nowhere ⇒ the clock is cleared, so a
|
|
240
|
+
* policy added later gives the full grace from that moment. Required (or in grace) anywhere ⇒
|
|
241
|
+
* kept, so switching nodes cannot dodge a deadline. Not wired, or a read error ⇒ kept (the safe
|
|
242
|
+
* direction; the sign-in itself already evaluated its own node).
|
|
243
|
+
*/
|
|
244
|
+
async clearClockIfRequiredNowhere(principal) {
|
|
245
|
+
const batch = this.deps.batch;
|
|
246
|
+
if (!batch || typeof batch.activeNodesOf !== 'function')
|
|
247
|
+
return;
|
|
248
|
+
try {
|
|
249
|
+
const nodeIds = await batch.activeNodesOf(principal.id);
|
|
250
|
+
const statuses = await this.statusesFor([
|
|
251
|
+
{ principalId: principal.id, isRoot: principal.isRoot === true, nodeIds },
|
|
252
|
+
]);
|
|
253
|
+
if (statuses.get(principal.id) === 'not_required') {
|
|
254
|
+
await this.deps.source.clearRequiredSince(principal.id);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Keep the clock.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The request-path recompute (R2.5). Returns the claims to enforce for THIS request: unchanged
|
|
263
|
+
* when nothing is due (NO DB query), else re-evaluated and recorded. `known.version` /
|
|
264
|
+
* `known.grants`: what gate 5 already has on a gated route. Throws
|
|
265
|
+
* {@link MfaRequirementUnavailableError} on any failure (the caller denies).
|
|
266
|
+
*/
|
|
267
|
+
async refresh(accessToken, claims, known) {
|
|
268
|
+
// Sessions only: a service-account key has no session state and no interactive enrolment.
|
|
269
|
+
if (claims.sessionId === '' || claims.kind === 'service_account')
|
|
270
|
+
return claims;
|
|
271
|
+
void accessToken; // keyed by session id: the session-claims entry is authoritative.
|
|
272
|
+
let version;
|
|
273
|
+
try {
|
|
274
|
+
const knownVersion = known?.version;
|
|
275
|
+
version = isVersion(knownVersion)
|
|
276
|
+
? knownVersion
|
|
277
|
+
: await this.deps.currentVersion(claims.tenantId);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
throw new MfaRequirementUnavailableError(); // Redis down ⇒ deny (ADR-0035)
|
|
281
|
+
}
|
|
282
|
+
if (!isVersion(version))
|
|
283
|
+
throw new MfaRequirementUnavailableError();
|
|
284
|
+
const stamp = claims.mfaRequirementVersion;
|
|
285
|
+
if (isVersion(stamp) && version <= stamp)
|
|
286
|
+
return claims; // unchanged: no DB query
|
|
287
|
+
try {
|
|
288
|
+
const grants = known?.grants ?? (await this.grantsOf(claims.id, claims.tenantId));
|
|
289
|
+
const req = await this.evaluateFor({
|
|
290
|
+
principalId: claims.id,
|
|
291
|
+
isRoot: claims.isRoot === true,
|
|
292
|
+
tenantId: claims.tenantId,
|
|
293
|
+
roleIds: (0, session_token_binder_js_1.liveRoleIds)(grants) ?? [...claims.roleIds],
|
|
294
|
+
permissionKeys: grants.grantedPermissionKeys,
|
|
295
|
+
});
|
|
296
|
+
const state = stateFrom(req, version);
|
|
297
|
+
// A transition of the mandatory-2FA reason writes the durable column first (lifecycle).
|
|
298
|
+
const wasRestricted = claims.restriction === 'mfa_enrollment';
|
|
299
|
+
const restrictions = this.deps.restrictions;
|
|
300
|
+
if (state.restriction === 'mfa_enrollment' &&
|
|
301
|
+
!wasRestricted &&
|
|
302
|
+
claims.mustReenroll !== true) {
|
|
303
|
+
if (typeof restrictions?.markRestriction === 'function') {
|
|
304
|
+
await restrictions.markRestriction(claims.sessionId, 'mfa_enrollment');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else if (state.restriction === undefined && wasRestricted) {
|
|
308
|
+
if (typeof restrictions?.clearMfaRestriction === 'function') {
|
|
309
|
+
await restrictions.clearMfaRestriction(claims.sessionId);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const binder = this.deps.binder;
|
|
313
|
+
const epoch = isVersion(claims.mfaStateEpoch) ? claims.mfaStateEpoch : 0;
|
|
314
|
+
if (binder === undefined || binder === null)
|
|
315
|
+
throw new MfaRequirementUnavailableError();
|
|
316
|
+
if (await binder.updateMfaRequirement(claims.sessionId, claims.tenantId, epoch, state)) {
|
|
317
|
+
return applyState(claims, state, epoch + 1);
|
|
318
|
+
}
|
|
319
|
+
// Refused (review round 1, item A): another recompute or a device confirm got there first.
|
|
320
|
+
// Enforce what the entry now holds when it is at least as new as this evaluation; never the
|
|
321
|
+
// state computed here. Older or gone ⇒ deny (the next request retries).
|
|
322
|
+
const stored = typeof binder.mfaRequirementOf === 'function'
|
|
323
|
+
? await binder.mfaRequirementOf(claims.sessionId, claims.tenantId)
|
|
324
|
+
: null;
|
|
325
|
+
if (stored === null ||
|
|
326
|
+
!isVersion(stored.mfaRequirementVersion) ||
|
|
327
|
+
stored.mfaRequirementVersion < version) {
|
|
328
|
+
throw new MfaRequirementUnavailableError();
|
|
329
|
+
}
|
|
330
|
+
await this.reconcileDurable(claims.sessionId, state, stored);
|
|
331
|
+
return applyStored(claims, stored);
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
if (err instanceof MfaRequirementUnavailableError)
|
|
335
|
+
throw err;
|
|
336
|
+
throw new MfaRequirementUnavailableError();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* After a refused write, bring the durable mandatory-2FA column back in line with the stored
|
|
341
|
+
* (authoritative) state when this recompute moved it the other way. Best-effort: the column is
|
|
342
|
+
* only read by a refresh rebuild, which is unstamped and so re-evaluated on its next request.
|
|
343
|
+
*/
|
|
344
|
+
async reconcileDurable(sessionId, attempted, stored) {
|
|
345
|
+
const restrictions = this.deps.restrictions;
|
|
346
|
+
const storedRestricted = stored.restriction === 'mfa_enrollment';
|
|
347
|
+
const attemptedRestricted = attempted.restriction === 'mfa_enrollment';
|
|
348
|
+
if (attemptedRestricted === storedRestricted || stored.restriction === 'reenroll')
|
|
349
|
+
return;
|
|
350
|
+
if (storedRestricted) {
|
|
351
|
+
await restrictions?.markRestriction?.(sessionId, 'mfa_enrollment').catch(() => undefined);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
await restrictions?.clearMfaRestriction?.(sessionId).catch(() => undefined);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* A factor was just confirmed on this session (R2.3, AC-7): lift the restriction (Redis claims
|
|
359
|
+
* and the durable mandatory-2FA reason) and delete the grace clock (R2.4). Best-effort, like the
|
|
360
|
+
* re-enrol clear: a failure leaves the session restricted (the safe direction).
|
|
361
|
+
*/
|
|
362
|
+
async onFactorConfirmed(claims) {
|
|
363
|
+
await this.deps.binder?.clearRestriction(claims.sessionId).catch(() => undefined);
|
|
364
|
+
if (claims.restriction === 'mfa_enrollment') {
|
|
365
|
+
await this.deps.restrictions?.clearMfaRestriction?.(claims.sessionId).catch(() => undefined);
|
|
366
|
+
}
|
|
367
|
+
await this.deps.source.clearRequiredSince(claims.id).catch(() => undefined);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* R2.7 defence in depth: a removal left no confirmed factor while a policy applies — restrict this
|
|
371
|
+
* session now (durable column, then the session-claims entry; see
|
|
372
|
+
* `SessionTokenBinder.restrictMfaEnrollment`) instead of waiting for the next sign-in. Throws when
|
|
373
|
+
* the restriction could not be recorded (the caller answers 503; the removal already happened, so
|
|
374
|
+
* the user retries into a restricted session or signs in again into one).
|
|
375
|
+
*/
|
|
376
|
+
async restrictNow(claims) {
|
|
377
|
+
if (claims.sessionId === '' || claims.kind === 'service_account')
|
|
378
|
+
return;
|
|
379
|
+
try {
|
|
380
|
+
if (claims.mustReenroll !== true) {
|
|
381
|
+
await this.deps.restrictions?.markRestriction?.(claims.sessionId, 'mfa_enrollment');
|
|
382
|
+
}
|
|
383
|
+
const binder = this.deps.binder;
|
|
384
|
+
if (binder === undefined ||
|
|
385
|
+
binder === null ||
|
|
386
|
+
typeof binder.restrictMfaEnrollment !== 'function' ||
|
|
387
|
+
!(await binder.restrictMfaEnrollment(claims.sessionId))) {
|
|
388
|
+
throw new MfaRequirementUnavailableError();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
throw new MfaRequirementUnavailableError();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Whether a policy applies to the session's principal at its active node (R2.7): true for
|
|
397
|
+
* `enrolled` / `grace` / `required`. Reads only, never the clock. Throws on failure.
|
|
398
|
+
*/
|
|
399
|
+
async requiresFactor(claims) {
|
|
400
|
+
const { source } = this.deps;
|
|
401
|
+
const grants = await this.grantsOf(claims.id, claims.tenantId);
|
|
402
|
+
const nodeIds = await source.scopeChainOf(claims.tenantId);
|
|
403
|
+
const policies = await source.loadPolicies(nodeIds);
|
|
404
|
+
const req = (0, core_1.evaluateMfaRequirement)({
|
|
405
|
+
policies,
|
|
406
|
+
subject: {
|
|
407
|
+
isRoot: claims.isRoot === true,
|
|
408
|
+
nodeIds,
|
|
409
|
+
roleIds: (0, session_token_binder_js_1.liveRoleIds)(grants) ?? [...claims.roleIds],
|
|
410
|
+
permissionKeys: grants.grantedPermissionKeys,
|
|
411
|
+
},
|
|
412
|
+
hasConfirmedFactor: true,
|
|
413
|
+
requiredSinceMs: null,
|
|
414
|
+
nowMs: this.now(),
|
|
415
|
+
});
|
|
416
|
+
return req.status !== 'not_required';
|
|
417
|
+
}
|
|
418
|
+
/** The grants for (principal, node), or throw: an evaluation never runs on guessed grants. */
|
|
419
|
+
async grantsOf(principalId, tenantId) {
|
|
420
|
+
const grants = await this.deps.loadGrants(principalId, tenantId);
|
|
421
|
+
if (grants === null || grants === undefined || !Array.isArray(grants.grantedPermissionKeys)) {
|
|
422
|
+
throw new MfaRequirementUnavailableError();
|
|
423
|
+
}
|
|
424
|
+
return grants;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
exports.MfaRequirementService = MfaRequirementService;
|
|
428
|
+
function isNonEmpty(v) {
|
|
429
|
+
return typeof v === 'string' && v.length > 0;
|
|
430
|
+
}
|
|
431
|
+
/** The claims with a recorded state applied (what this request enforces). */
|
|
432
|
+
function applyState(claims, state, epoch) {
|
|
433
|
+
const { restriction: _r, mfaEnrollmentDeadlineMs: _d, mfaRequirementVersion: _v, mfaStateEpoch: _e, ...rest } = claims;
|
|
434
|
+
return {
|
|
435
|
+
...rest,
|
|
436
|
+
...(claims.restriction === 'reenroll' ? { restriction: 'reenroll' } : {}),
|
|
437
|
+
...(state.restriction === 'mfa_enrollment' && claims.restriction !== 'reenroll'
|
|
438
|
+
? { restriction: 'mfa_enrollment' }
|
|
439
|
+
: {}),
|
|
440
|
+
...(typeof state.mfaEnrollmentDeadlineMs === 'number'
|
|
441
|
+
? { mfaEnrollmentDeadlineMs: state.mfaEnrollmentDeadlineMs }
|
|
442
|
+
: {}),
|
|
443
|
+
mfaRequirementVersion: state.mfaRequirementVersion,
|
|
444
|
+
mfaStateEpoch: epoch,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/** The claims with the STORED state applied (a refused write: the entry is authoritative). */
|
|
448
|
+
function applyStored(claims, stored) {
|
|
449
|
+
const { restriction: _r, mfaEnrollmentDeadlineMs: _d, mfaRequirementVersion: _v, mfaStateEpoch: _e, ...rest } = claims;
|
|
450
|
+
return {
|
|
451
|
+
...rest,
|
|
452
|
+
...(stored.restriction !== undefined ? { restriction: stored.restriction } : {}),
|
|
453
|
+
...(stored.mfaEnrollmentDeadlineMs !== undefined
|
|
454
|
+
? { mfaEnrollmentDeadlineMs: stored.mfaEnrollmentDeadlineMs }
|
|
455
|
+
: {}),
|
|
456
|
+
...(stored.mfaRequirementVersion !== undefined
|
|
457
|
+
? { mfaRequirementVersion: stored.mfaRequirementVersion }
|
|
458
|
+
: {}),
|
|
459
|
+
mfaStateEpoch: stored.mfaStateEpoch ?? 0,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* - the session's RE-ENROLL restriction (`shield_identity.session.s_must_reenroll`, identity
|
|
8
8
|
* migration 1710000000019): set at a recovery-code login, cleared when a fresh MFA device is
|
|
9
9
|
* confirmed. A restricted session rebuilds `mustReenroll: true` and never root;
|
|
10
|
+
* - the restriction REASON (`session.s_restriction`, identity migration 1710000000104, auth
|
|
11
|
+
* capabilities D-2): `'reenroll'` (written with `s_must_reenroll`, decision (1)) or
|
|
12
|
+
* `'mfa_enrollment'` (mandatory 2FA, R2.3). Read as `s_restriction ?? (s_must_reenroll ?
|
|
13
|
+
* 'reenroll' : null)`. Without migration 104 the re-enrol statements fall back to
|
|
14
|
+
* `s_must_reenroll` alone (errno 1054), so older databases keep working;
|
|
10
15
|
* - the ACCOUNT STATUS (`shield_app.users.u_status`, the identity org table the default profile
|
|
11
16
|
* resolver already reads): disabling a user does not revoke its sessions, so a disabled user
|
|
12
17
|
* gets no rebuild.
|
|
@@ -20,6 +25,11 @@ import { type FrameworkSchemaNames } from '../config/framework-schema-names.js';
|
|
|
20
25
|
export interface SessionRebuildFacts {
|
|
21
26
|
/** The session was opened by a recovery-code login and is not yet re-secured. */
|
|
22
27
|
readonly mustReenroll: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The durable restriction reason (auth capabilities D-2, decision (1)):
|
|
30
|
+
* `s_restriction ?? (s_must_reenroll ? 'reenroll' : null)`. Absent on stores that predate it.
|
|
31
|
+
*/
|
|
32
|
+
readonly restriction?: 'reenroll' | 'mfa_enrollment' | null;
|
|
23
33
|
/**
|
|
24
34
|
* `false` when the principal's account row exists and is not `active`. `true` when it is active
|
|
25
35
|
* or when no `shield_app.users` table exists (a guard-only app that keeps users elsewhere).
|
|
@@ -31,6 +41,13 @@ export interface SessionRestrictionStore {
|
|
|
31
41
|
markMustReenroll(sessionId: string): Promise<void>;
|
|
32
42
|
/** Lift it (a fresh device was confirmed). Throws on error. */
|
|
33
43
|
clearMustReenroll(sessionId: string): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Persist the mandatory-2FA restriction (R2.3) on the durable session; never over a re-enrol
|
|
46
|
+
* restriction. Throws on error. Optional so older test doubles still type-check.
|
|
47
|
+
*/
|
|
48
|
+
markRestriction?(sessionId: string, reason: 'mfa_enrollment'): Promise<void>;
|
|
49
|
+
/** Lift the mandatory-2FA restriction only (the re-enrol one stays). Throws on error. */
|
|
50
|
+
clearMfaRestriction?(sessionId: string): Promise<void>;
|
|
34
51
|
/** Read the restriction + account status for a rebuild. Throws on error (→ no rebuild). */
|
|
35
52
|
rebuildFacts(sessionId: string, principalId: string): Promise<SessionRebuildFacts>;
|
|
36
53
|
}
|
|
@@ -40,11 +57,30 @@ export declare class MariaDbSessionRestrictionStore implements SessionRestrictio
|
|
|
40
57
|
private readonly sessionTable;
|
|
41
58
|
/** `<app>`.`users`. */
|
|
42
59
|
private readonly usersTable;
|
|
60
|
+
/**
|
|
61
|
+
* Set once a statement found no `s_restriction` column (migration 104 not applied): the re-enrol
|
|
62
|
+
* statements then use `s_must_reenroll` alone, exactly as before 104.
|
|
63
|
+
*/
|
|
64
|
+
private legacyColumns;
|
|
43
65
|
constructor(pool: Pool,
|
|
44
66
|
/** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
|
|
45
67
|
schemas?: FrameworkSchemaNames);
|
|
68
|
+
/**
|
|
69
|
+
* Run `current` (it names `s_restriction`), or `legacy` once the column is known to be absent
|
|
70
|
+
* (errno 1054 on the first try). Any other error propagates.
|
|
71
|
+
*/
|
|
72
|
+
private withLegacyFallback;
|
|
73
|
+
/** Decision (1): re-enrol writes both columns (`s_restriction` and the kept `s_must_reenroll`). */
|
|
46
74
|
markMustReenroll(sessionId: string): Promise<void>;
|
|
75
|
+
/** A fresh device was confirmed: lift BOTH reasons (R2.3: confirm lifts either restriction). */
|
|
47
76
|
clearMustReenroll(sessionId: string): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* The mandatory-2FA restriction (R2.3), never over a re-enrol one (that row keeps its reason).
|
|
79
|
+
* Needs migration 104 (only written when the policy feature is on); errors propagate.
|
|
80
|
+
*/
|
|
81
|
+
markRestriction(sessionId: string, reason: 'mfa_enrollment'): Promise<void>;
|
|
82
|
+
/** Lift only the mandatory-2FA reason (the requirement no longer applies). */
|
|
83
|
+
clearMfaRestriction(sessionId: string): Promise<void>;
|
|
48
84
|
rebuildFacts(sessionId: string, principalId: string): Promise<SessionRebuildFacts>;
|
|
49
85
|
}
|
|
50
86
|
//# sourceMappingURL=session-restriction-store.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-restriction-store.d.ts","sourceRoot":"","sources":["../../src/session/session-restriction-store.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"session-restriction-store.d.ts","sourceRoot":"","sources":["../../src/session/session-restriction-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,qCAAqC,CAAC;AAE7C,gFAAgF;AAChF,MAAM,WAAW,mBAAmB;IAClC,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAAC;IAC5D;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;CACjC;AAED,MAAM,WAAW,uBAAuB;IACtC,uGAAuG;IACvG,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,+DAA+D;IAC/D,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD;;;OAGG;IACH,eAAe,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,yFAAyF;IACzF,mBAAmB,CAAC,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,2FAA2F;IAC3F,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACpF;AAWD,qBAAa,8BAA+B,YAAW,uBAAuB;IAY1E,OAAO,CAAC,QAAQ,CAAC,IAAI;IAXvB,8BAA8B;IAC9B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,uBAAuB;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC;;;OAGG;IACH,OAAO,CAAC,aAAa,CAAS;gBAGX,IAAI,EAAE,IAAI;IAC3B,yFAAyF;IACzF,OAAO,CAAC,EAAE,oBAAoB;IAOhC;;;OAGG;YACW,kBAAkB;IAWhC,mGAAmG;IAC7F,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAexD,gGAAgG;IAC1F,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAezD;;;OAGG;IACG,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAOjF,8EAA8E;IACxE,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOrD,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;CA0CzF"}
|
|
@@ -4,12 +4,22 @@ exports.MariaDbSessionRestrictionStore = void 0;
|
|
|
4
4
|
const framework_schema_names_js_1 = require("../config/framework-schema-names.js");
|
|
5
5
|
/** MariaDB `ER_NO_SUCH_TABLE`. */
|
|
6
6
|
const ER_NO_SUCH_TABLE = 1146;
|
|
7
|
+
/** MariaDB `ER_BAD_FIELD_ERROR`: `s_restriction` absent (an app without identity migration 104). */
|
|
8
|
+
const ER_BAD_FIELD_ERROR = 1054;
|
|
9
|
+
function errnoOf(err) {
|
|
10
|
+
return err?.errno;
|
|
11
|
+
}
|
|
7
12
|
class MariaDbSessionRestrictionStore {
|
|
8
13
|
pool;
|
|
9
14
|
/** `<identity>`.`session`. */
|
|
10
15
|
sessionTable;
|
|
11
16
|
/** `<app>`.`users`. */
|
|
12
17
|
usersTable;
|
|
18
|
+
/**
|
|
19
|
+
* Set once a statement found no `s_restriction` column (migration 104 not applied): the re-enrol
|
|
20
|
+
* statements then use `s_must_reenroll` alone, exactly as before 104.
|
|
21
|
+
*/
|
|
22
|
+
legacyColumns = false;
|
|
13
23
|
constructor(pool,
|
|
14
24
|
/** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
|
|
15
25
|
schemas) {
|
|
@@ -18,16 +28,55 @@ class MariaDbSessionRestrictionStore {
|
|
|
18
28
|
this.sessionTable = (0, framework_schema_names_js_1.qualifiedTableName)(s.identity, 'session');
|
|
19
29
|
this.usersTable = (0, framework_schema_names_js_1.qualifiedTableName)(s.app, 'users');
|
|
20
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Run `current` (it names `s_restriction`), or `legacy` once the column is known to be absent
|
|
33
|
+
* (errno 1054 on the first try). Any other error propagates.
|
|
34
|
+
*/
|
|
35
|
+
async withLegacyFallback(current, legacy) {
|
|
36
|
+
if (this.legacyColumns)
|
|
37
|
+
return legacy();
|
|
38
|
+
try {
|
|
39
|
+
return await current();
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (errnoOf(err) !== ER_BAD_FIELD_ERROR)
|
|
43
|
+
throw err;
|
|
44
|
+
this.legacyColumns = true;
|
|
45
|
+
return legacy();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Decision (1): re-enrol writes both columns (`s_restriction` and the kept `s_must_reenroll`). */
|
|
21
49
|
async markMustReenroll(sessionId) {
|
|
22
|
-
await this.pool.query(`UPDATE ${this.sessionTable} SET s_must_reenroll = 1 WHERE s_session_id = ?`, [sessionId]);
|
|
50
|
+
await this.withLegacyFallback(() => this.pool.query(`UPDATE ${this.sessionTable} SET s_must_reenroll = 1, s_restriction = 'reenroll' WHERE s_session_id = ?`, [sessionId]), () => this.pool.query(`UPDATE ${this.sessionTable} SET s_must_reenroll = 1 WHERE s_session_id = ?`, [sessionId]));
|
|
23
51
|
}
|
|
52
|
+
/** A fresh device was confirmed: lift BOTH reasons (R2.3: confirm lifts either restriction). */
|
|
24
53
|
async clearMustReenroll(sessionId) {
|
|
25
|
-
await this.pool.query(`UPDATE ${this.sessionTable} SET s_must_reenroll = 0 WHERE s_session_id = ?`, [sessionId]);
|
|
54
|
+
await this.withLegacyFallback(() => this.pool.query(`UPDATE ${this.sessionTable} SET s_must_reenroll = 0, s_restriction = NULL WHERE s_session_id = ?`, [sessionId]), () => this.pool.query(`UPDATE ${this.sessionTable} SET s_must_reenroll = 0 WHERE s_session_id = ?`, [sessionId]));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The mandatory-2FA restriction (R2.3), never over a re-enrol one (that row keeps its reason).
|
|
58
|
+
* Needs migration 104 (only written when the policy feature is on); errors propagate.
|
|
59
|
+
*/
|
|
60
|
+
async markRestriction(sessionId, reason) {
|
|
61
|
+
await this.pool.query(`UPDATE ${this.sessionTable} SET s_restriction = ? WHERE s_session_id = ? AND s_must_reenroll = 0 AND (s_restriction IS NULL OR s_restriction <> 'reenroll')`, [reason, sessionId]);
|
|
62
|
+
}
|
|
63
|
+
/** Lift only the mandatory-2FA reason (the requirement no longer applies). */
|
|
64
|
+
async clearMfaRestriction(sessionId) {
|
|
65
|
+
await this.pool.query(`UPDATE ${this.sessionTable} SET s_restriction = NULL WHERE s_session_id = ? AND s_restriction = 'mfa_enrollment'`, [sessionId]);
|
|
26
66
|
}
|
|
27
67
|
async rebuildFacts(sessionId, principalId) {
|
|
28
|
-
const rows = (await this.pool.query(`SELECT s_must_reenroll FROM ${this.sessionTable} WHERE s_session_id = ? LIMIT 1`, [sessionId]));
|
|
68
|
+
const rows = await this.withLegacyFallback(async () => (await this.pool.query(`SELECT s_must_reenroll, s_restriction FROM ${this.sessionTable} WHERE s_session_id = ? LIMIT 1`, [sessionId])), async () => (await this.pool.query(`SELECT s_must_reenroll FROM ${this.sessionTable} WHERE s_session_id = ? LIMIT 1`, [sessionId])));
|
|
29
69
|
// No row → treat as restricted (the caller also re-reads the session and refuses a missing one).
|
|
30
|
-
const
|
|
70
|
+
const row = rows[0];
|
|
71
|
+
const stored = row?.s_restriction;
|
|
72
|
+
// Decision (1): `s_restriction ?? (s_must_reenroll ? 'reenroll' : null)`. An unknown stored
|
|
73
|
+
// value is still a restriction (fail closed).
|
|
74
|
+
const restriction = stored === undefined || stored === null
|
|
75
|
+
? null
|
|
76
|
+
: stored === 'reenroll'
|
|
77
|
+
? 'reenroll'
|
|
78
|
+
: 'mfa_enrollment';
|
|
79
|
+
const mustReenroll = row === undefined || Number(row.s_must_reenroll) !== 0 || restriction === 'reenroll';
|
|
31
80
|
let accountActive = true;
|
|
32
81
|
try {
|
|
33
82
|
const users = (await this.pool.query(`SELECT u_status FROM ${this.usersTable} WHERE u_uuid = ? LIMIT 1`, [principalId]));
|
|
@@ -36,10 +85,12 @@ class MariaDbSessionRestrictionStore {
|
|
|
36
85
|
}
|
|
37
86
|
catch (err) {
|
|
38
87
|
// Only an absent org table means "this app keeps no users here"; any other error fails closed.
|
|
39
|
-
if (err
|
|
88
|
+
if (errnoOf(err) !== ER_NO_SUCH_TABLE)
|
|
40
89
|
throw err;
|
|
41
90
|
}
|
|
42
|
-
return
|
|
91
|
+
return restriction === null
|
|
92
|
+
? { mustReenroll, accountActive }
|
|
93
|
+
: { mustReenroll, restriction, accountActive };
|
|
43
94
|
}
|
|
44
95
|
}
|
|
45
96
|
exports.MariaDbSessionRestrictionStore = MariaDbSessionRestrictionStore;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The restricted-session reason (auth capabilities D-2, R2.3, R2.4) — one reading, used by every
|
|
3
|
+
* check: the global guard (gated routes) and the self-authenticating `@Public` routes
|
|
4
|
+
* (`/account/*`, `/auth/scope-switch`, `/identity/auth-facts`).
|
|
5
|
+
*
|
|
6
|
+
* - `'reenroll'`: a recovery-code login (`mustReenroll: true`, the old form, still written so old
|
|
7
|
+
* bindings and readers keep working). Checked first: its step-up exemptions differ.
|
|
8
|
+
* - `'mfa_enrollment'`: a mandatory-2FA policy applies and the user has no confirmed factor
|
|
9
|
+
* (`restriction`), or the grace deadline has passed (`mfaEnrollmentDeadlineMs <= now`, compared
|
|
10
|
+
* here on every request, no DB read).
|
|
11
|
+
*
|
|
12
|
+
* Both reasons limit the session to `REENROLL_ALLOWED`; each has its own 403 code.
|
|
13
|
+
*/
|
|
14
|
+
import type { BoundPrincipalClaims } from '../request-auth/session-token-binder.js';
|
|
15
|
+
export type SessionRestrictionReason = 'reenroll' | 'mfa_enrollment';
|
|
16
|
+
type RestrictionClaims = Pick<BoundPrincipalClaims, 'mustReenroll'> & Partial<Pick<BoundPrincipalClaims, 'restriction' | 'mfaEnrollmentDeadlineMs'>>;
|
|
17
|
+
/** The session's effective restriction at `nowMs`, or `null` when unrestricted. */
|
|
18
|
+
export declare function restrictionOf(claims: RestrictionClaims, nowMs: number): SessionRestrictionReason | null;
|
|
19
|
+
/** The 403 body code for a reason (`{ error: <code> }`). */
|
|
20
|
+
export declare function restrictionErrorCode(reason: SessionRestrictionReason): 'reenrollment_required' | 'mfa_enrollment_required';
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=session-restriction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-restriction.d.ts","sourceRoot":"","sources":["../../src/session/session-restriction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yCAAyC,CAAC;AAEpF,MAAM,MAAM,wBAAwB,GAAG,UAAU,GAAG,gBAAgB,CAAC;AAErE,KAAK,iBAAiB,GAAG,IAAI,CAAC,oBAAoB,EAAE,cAAc,CAAC,GACjE,OAAO,CAAC,IAAI,CAAC,oBAAoB,EAAE,aAAa,GAAG,yBAAyB,CAAC,CAAC,CAAC;AAEjF,mFAAmF;AACnF,wBAAgB,aAAa,CAC3B,MAAM,EAAE,iBAAiB,EACzB,KAAK,EAAE,MAAM,GACZ,wBAAwB,GAAG,IAAI,CAWjC;AAED,4DAA4D;AAC5D,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,wBAAwB,GAC/B,uBAAuB,GAAG,yBAAyB,CAErD"}
|