@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
|
@@ -133,6 +133,46 @@ function stripCachedFacts(claims) {
|
|
|
133
133
|
const { cachedFacts: _drop, ...rest } = claims;
|
|
134
134
|
return rest;
|
|
135
135
|
}
|
|
136
|
+
/** The mandatory-2FA state keys (D-2), authoritative on the session-claims entry. */
|
|
137
|
+
const MFA_STATE_KEYS = [
|
|
138
|
+
'restriction',
|
|
139
|
+
'mfaEnrollmentDeadlineMs',
|
|
140
|
+
'mfaRequirementVersion',
|
|
141
|
+
'mfaStateEpoch',
|
|
142
|
+
];
|
|
143
|
+
/**
|
|
144
|
+
* Read the mandatory-2FA state from an (untrusted) session-claims payload. Fail closed: an unknown
|
|
145
|
+
* reason counts as `'mfa_enrollment'`, a non-numeric deadline as passed (0); a junk stamp or epoch is
|
|
146
|
+
* dropped (→ the next request re-evaluates; the compare-and-set reads it as 0).
|
|
147
|
+
*/
|
|
148
|
+
function readMfaState(sess) {
|
|
149
|
+
const out = {};
|
|
150
|
+
if (sess === null || typeof sess !== 'object')
|
|
151
|
+
return out;
|
|
152
|
+
const s = sess;
|
|
153
|
+
if (s.restriction !== undefined && s.restriction !== null) {
|
|
154
|
+
out.restriction = s.restriction === 'reenroll' ? 'reenroll' : 'mfa_enrollment';
|
|
155
|
+
}
|
|
156
|
+
if (s.mfaEnrollmentDeadlineMs !== undefined && s.mfaEnrollmentDeadlineMs !== null) {
|
|
157
|
+
const d = s.mfaEnrollmentDeadlineMs;
|
|
158
|
+
out.mfaEnrollmentDeadlineMs = typeof d === 'number' && Number.isFinite(d) ? d : 0;
|
|
159
|
+
}
|
|
160
|
+
if (isSubtreeVersion(s.mfaRequirementVersion)) {
|
|
161
|
+
out.mfaRequirementVersion = s.mfaRequirementVersion;
|
|
162
|
+
}
|
|
163
|
+
if (isSubtreeVersion(s.mfaStateEpoch))
|
|
164
|
+
out.mfaStateEpoch = s.mfaStateEpoch;
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/** Return the claims without any mandatory-2FA state key. */
|
|
168
|
+
function stripMfaState(claims) {
|
|
169
|
+
if (!MFA_STATE_KEYS.some((k) => k in claims))
|
|
170
|
+
return claims;
|
|
171
|
+
const next = { ...claims };
|
|
172
|
+
for (const k of MFA_STATE_KEYS)
|
|
173
|
+
delete next[k];
|
|
174
|
+
return next;
|
|
175
|
+
}
|
|
136
176
|
/** Redis key prefix for the access-token → claims binding. */
|
|
137
177
|
const AT_KEY_PREFIX = 'shield:at:';
|
|
138
178
|
/** Redis key prefix for the session-id → claims binding (survives token rotation). */
|
|
@@ -204,6 +244,18 @@ end
|
|
|
204
244
|
redis.call('DEL', KEYS[5])
|
|
205
245
|
return 1`;
|
|
206
246
|
const REBIND_REFUSED = 0;
|
|
247
|
+
/**
|
|
248
|
+
* The mandatory-2FA recompute's compare-and-set (review round 1, item A): write ARGV[2] only if the
|
|
249
|
+
* entry is still exactly ARGV[1] (the value the caller read and judged), keeping its remaining TTL.
|
|
250
|
+
* One script, so of N concurrent recomputes exactly one writes. Returns 1 when written, else 0.
|
|
251
|
+
*/
|
|
252
|
+
const MFA_STATE_CAS_SCRIPT = `
|
|
253
|
+
local cur = redis.call('GET', KEYS[1])
|
|
254
|
+
if cur ~= ARGV[1] then return 0 end
|
|
255
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
256
|
+
if ttl <= 0 then return 0 end
|
|
257
|
+
redis.call('SET', KEYS[1], ARGV[2], 'PX', ttl, 'XX')
|
|
258
|
+
return 1`;
|
|
207
259
|
/** How long refresh waits for Redis to answer PING before answering 503 (ADR-0035, F1). */
|
|
208
260
|
const DEFAULT_PING_TIMEOUT_MS = 1_000;
|
|
209
261
|
class SessionTokenBinder {
|
|
@@ -521,12 +573,19 @@ class SessionTokenBinder {
|
|
|
521
573
|
if (rawAt === null || rawSess === null)
|
|
522
574
|
return false; // token/session gone → fail closed.
|
|
523
575
|
let current;
|
|
576
|
+
let sessMfa;
|
|
524
577
|
try {
|
|
525
578
|
current = JSON.parse(rawAt);
|
|
579
|
+
sessMfa = readMfaState(JSON.parse(rawSess));
|
|
526
580
|
}
|
|
527
581
|
catch {
|
|
528
582
|
return false; // malformed → fail closed (a later request re-denies anyway).
|
|
529
583
|
}
|
|
584
|
+
// The mandatory-2FA state (D-2) comes from the authoritative session-claims entry and is kept
|
|
585
|
+
// across the switch (a restriction or a grace deadline never drops on a switch), minus the
|
|
586
|
+
// requirement stamp: versions are per node, so the next request re-evaluates for the new node.
|
|
587
|
+
const { mfaRequirementVersion: _perNode, ...keptMfa } = sessMfa;
|
|
588
|
+
current = { ...stripMfaState(current), ...keptMfa };
|
|
530
589
|
// Build the re-scoped claims: new active node (tenantId) + new subtree, facts stripped.
|
|
531
590
|
// An empty subtree is stored as absent (→ per-request self-node fallback, non-widening) —
|
|
532
591
|
// but the switch caller always passes at least `[targetNodeId]`, so this is defensive.
|
|
@@ -693,14 +752,157 @@ class SessionTokenBinder {
|
|
|
693
752
|
// legacy session-claims entry that lacks the flag is treated as `false` (default
|
|
694
753
|
// safe: an old binding predating this field must not spuriously lock a session).
|
|
695
754
|
let mustReenroll = parsed.mustReenroll;
|
|
755
|
+
// The mandatory-2FA state (D-2) is authoritative on the same entry, for the same reason: a
|
|
756
|
+
// recompute or a device confirm keyed by session id reaches every live token at once. The
|
|
757
|
+
// per-token copies are never trusted.
|
|
758
|
+
let mfa = {};
|
|
696
759
|
try {
|
|
697
760
|
const sess = JSON.parse(liveness);
|
|
698
761
|
mustReenroll = sess?.mustReenroll === true;
|
|
762
|
+
mfa = readMfaState(sess);
|
|
699
763
|
}
|
|
700
764
|
catch {
|
|
701
765
|
mustReenroll = false; // unparseable session-claims → treat as not-reenroll.
|
|
702
766
|
}
|
|
703
|
-
|
|
767
|
+
const hasMfa = Object.keys(mfa).length > 0;
|
|
768
|
+
if (!hasMfa && !MFA_STATE_KEYS.some((k) => k in parsed)) {
|
|
769
|
+
return mustReenroll === parsed.mustReenroll ? parsed : { ...parsed, mustReenroll };
|
|
770
|
+
}
|
|
771
|
+
return { ...stripMfaState(parsed), mustReenroll, ...mfa };
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* MANDATORY-2FA RECOMPUTE write (R2.5, D-2): store a re-evaluated requirement on the
|
|
775
|
+
* session-claims entry (authoritative for every live token of the session; see `resolve()`).
|
|
776
|
+
* Compare-and-set, so a slow recompute never undoes a newer change:
|
|
777
|
+
* - the entry must still belong to `sessionId` + `tenantId` (a scope-switch moved it → refused);
|
|
778
|
+
* - its `mfaStateEpoch` (absent = 0) must equal `expectedEpoch`, the value the evaluation started
|
|
779
|
+
* from (a device confirm or another recompute since → refused);
|
|
780
|
+
* - a stored stamp NEWER than `state.mfaRequirementVersion` wins (nothing written, refused).
|
|
781
|
+
* The write is ONE script that compares the exact entry it read (review round 1, item A), so of N
|
|
782
|
+
* concurrent recomputes exactly one writes; it keeps the remaining TTL (never extends the
|
|
783
|
+
* session) and increments the epoch. The re-enrol flag is never touched. Returns `false` when
|
|
784
|
+
* refused, gone, or Redis failed — the caller then re-reads the stored state with
|
|
785
|
+
* {@link mfaRequirementOf} and enforces it, or denies (fail closed).
|
|
786
|
+
*/
|
|
787
|
+
async updateMfaRequirement(sessionId, tenantId, expectedEpoch, state) {
|
|
788
|
+
const key = sessClaimsKey(sessionId);
|
|
789
|
+
try {
|
|
790
|
+
const raw = await this.redis.get(key);
|
|
791
|
+
if (raw === null)
|
|
792
|
+
return false;
|
|
793
|
+
const current = JSON.parse(raw);
|
|
794
|
+
if (current?.sessionId !== sessionId || current?.tenantId !== tenantId)
|
|
795
|
+
return false;
|
|
796
|
+
const stored = readMfaState(current);
|
|
797
|
+
if ((stored.mfaStateEpoch ?? 0) !== expectedEpoch)
|
|
798
|
+
return false;
|
|
799
|
+
if (stored.mfaRequirementVersion !== undefined &&
|
|
800
|
+
stored.mfaRequirementVersion > state.mfaRequirementVersion) {
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
const next = {
|
|
804
|
+
...stripMfaState(current),
|
|
805
|
+
...(stored.restriction === 'reenroll' ? { restriction: 'reenroll' } : {}),
|
|
806
|
+
...(state.restriction === 'mfa_enrollment' && stored.restriction !== 'reenroll'
|
|
807
|
+
? { restriction: 'mfa_enrollment' }
|
|
808
|
+
: {}),
|
|
809
|
+
...(typeof state.mfaEnrollmentDeadlineMs === 'number'
|
|
810
|
+
? { mfaEnrollmentDeadlineMs: state.mfaEnrollmentDeadlineMs }
|
|
811
|
+
: {}),
|
|
812
|
+
mfaRequirementVersion: state.mfaRequirementVersion,
|
|
813
|
+
mfaStateEpoch: expectedEpoch + 1,
|
|
814
|
+
};
|
|
815
|
+
const written = await this.redis.eval(MFA_STATE_CAS_SCRIPT, 1, key, raw, JSON.stringify(next));
|
|
816
|
+
return Number(written) === 1;
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* The mandatory-2FA state stored on the session-claims entry, for a recompute whose
|
|
824
|
+
* compare-and-set was refused (review round 1, item A): `null` when the entry is gone, belongs to
|
|
825
|
+
* another node, or Redis failed. A Redis error yields `null` (the caller denies).
|
|
826
|
+
*/
|
|
827
|
+
async mfaRequirementOf(sessionId, tenantId) {
|
|
828
|
+
try {
|
|
829
|
+
const raw = await this.redis.get(sessClaimsKey(sessionId));
|
|
830
|
+
if (raw === null)
|
|
831
|
+
return null;
|
|
832
|
+
const current = JSON.parse(raw);
|
|
833
|
+
if (current?.sessionId !== sessionId || current?.tenantId !== tenantId)
|
|
834
|
+
return null;
|
|
835
|
+
return readMfaState(current);
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* R2.7 defence in depth: restrict the session NOW (`restriction: 'mfa_enrollment'`, any grace
|
|
843
|
+
* deadline dropped) after a removal left the user with no confirmed factor while a policy applies.
|
|
844
|
+
* Unconditional rather than compare-and-set (restricting is always the safe direction), and it
|
|
845
|
+
* increments the epoch, so an in-flight recompute that read the old state cannot lift it. The
|
|
846
|
+
* re-enrol flag and the requirement stamp are kept. Returns `false` when the entry is gone or Redis
|
|
847
|
+
* failed.
|
|
848
|
+
*/
|
|
849
|
+
async restrictMfaEnrollment(sessionId) {
|
|
850
|
+
const key = sessClaimsKey(sessionId);
|
|
851
|
+
try {
|
|
852
|
+
const raw = await this.redis.get(key);
|
|
853
|
+
if (raw === null)
|
|
854
|
+
return false;
|
|
855
|
+
const current = JSON.parse(raw);
|
|
856
|
+
const stored = readMfaState(current);
|
|
857
|
+
const ttl = await this.redis.pttl(key);
|
|
858
|
+
if (ttl <= 0)
|
|
859
|
+
return false;
|
|
860
|
+
const next = {
|
|
861
|
+
...stripMfaState(current),
|
|
862
|
+
restriction: stored.restriction === 'reenroll' ? 'reenroll' : 'mfa_enrollment',
|
|
863
|
+
...(stored.mfaRequirementVersion !== undefined
|
|
864
|
+
? { mfaRequirementVersion: stored.mfaRequirementVersion }
|
|
865
|
+
: {}),
|
|
866
|
+
mfaStateEpoch: (stored.mfaStateEpoch ?? 0) + 1,
|
|
867
|
+
};
|
|
868
|
+
return (await this.redis.set(key, JSON.stringify(next), 'PX', ttl, 'XX')) === 'OK';
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* DEVICE CONFIRM clear (R2.3, AC-7): the user now has a confirmed factor, so lift BOTH reasons
|
|
876
|
+
* (`mustReenroll` and `restriction`) and any grace deadline on the session-claims entry, keeping
|
|
877
|
+
* its remaining TTL and the requirement stamp, and increment the epoch so an in-flight recompute
|
|
878
|
+
* that started before the confirm cannot write its stale result back. The live token's next
|
|
879
|
+
* request is unrestricted (it is not re-minted). Best-effort: a gone entry or a Redis error leaves
|
|
880
|
+
* the session as it is (still restricted: the safe direction).
|
|
881
|
+
*/
|
|
882
|
+
async clearRestriction(sessionId) {
|
|
883
|
+
const key = sessClaimsKey(sessionId);
|
|
884
|
+
try {
|
|
885
|
+
const raw = await this.redis.get(key);
|
|
886
|
+
if (raw === null)
|
|
887
|
+
return;
|
|
888
|
+
const current = JSON.parse(raw);
|
|
889
|
+
const stored = readMfaState(current);
|
|
890
|
+
const ttl = await this.redis.pttl(key);
|
|
891
|
+
if (ttl <= 0)
|
|
892
|
+
return;
|
|
893
|
+
const next = {
|
|
894
|
+
...stripMfaState(current),
|
|
895
|
+
mustReenroll: false,
|
|
896
|
+
...(stored.mfaRequirementVersion !== undefined
|
|
897
|
+
? { mfaRequirementVersion: stored.mfaRequirementVersion }
|
|
898
|
+
: {}),
|
|
899
|
+
mfaStateEpoch: (stored.mfaStateEpoch ?? 0) + 1,
|
|
900
|
+
};
|
|
901
|
+
await this.redis.set(key, JSON.stringify(next), 'PX', ttl, 'XX');
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// best-effort: the session stays restricted; the user can confirm again or sign in again.
|
|
905
|
+
}
|
|
704
906
|
}
|
|
705
907
|
/**
|
|
706
908
|
* Clear the ENFORCED-RE-ENROLL flag for a session — the re-secure is complete
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MfaRequirementService — mandatory 2FA in the guard (auth capabilities R2.3–R2.5, R2.7, D-2, D-11).
|
|
3
|
+
* One evaluation (core `evaluateMfaRequirement`, B-2) over the guard's `MfaRequirementSource` (B-1),
|
|
4
|
+
* used at four points:
|
|
5
|
+
*
|
|
6
|
+
* 1. SIGN-IN ({@link atLogin}, a lifecycle event: DB reads allowed). The result is bound on the
|
|
7
|
+
* session: `'required'` ⇒ `restriction: 'mfa_enrollment'` (the controller also marks the durable
|
|
8
|
+
* column), `'grace'` ⇒ `mfaEnrollmentDeadlineMs`; always the version stamp the grants were read
|
|
9
|
+
* at. Any failure throws; the controller revokes the session and answers 503 (fail closed).
|
|
10
|
+
* 2. THE REQUEST PATH ({@link refresh}), on gated AND self-authenticating routes. It re-evaluates
|
|
11
|
+
* ONLY when the tenant's current permission version is newer than the session's stamp (or there
|
|
12
|
+
* is no stamp yet); otherwise it returns at once with NO DB query (N1, Shield #12, ADR-0035).
|
|
13
|
+
* Gated routes pass the version (and the reloaded grants) gate 5 already has; self-authenticating
|
|
14
|
+
* routes read the version from Redis ({@link currentVersionReader}: a hit is Redis only, a miss is
|
|
15
|
+
* the one DB fill gate 5 also does, a Redis error denies). A transition writes the durable column
|
|
16
|
+
* (a lifecycle write), then the session-claims entry by compare-and-set
|
|
17
|
+
* (`SessionTokenBinder.updateMfaRequirement`). Any failure throws
|
|
18
|
+
* {@link MfaRequirementUnavailableError} (503) and advances nothing, so the next request retries.
|
|
19
|
+
* 3. DEVICE CONFIRM ({@link onFactorConfirmed}): lifts the restriction on the current session (Redis
|
|
20
|
+
* claims and the durable column) and deletes the grace clock (AC-7, AC-10).
|
|
21
|
+
* 4. LAST-DEVICE REMOVAL ({@link requiresFactor}, R2.7): whether any policy applies (no clock write).
|
|
22
|
+
*
|
|
23
|
+
* The subject (decision (2) of slice D): the node chain comes from the session's ACTIVE node (scope
|
|
24
|
+
* closure); roles and permission keys from the grants (gate 5's reload, or the session-facts
|
|
25
|
+
* projector, the same `loadCoarseGrants` the decision authority uses). Root accounts are subject
|
|
26
|
+
* only to platform-floor policies (D-11, in the core rule).
|
|
27
|
+
*
|
|
28
|
+
* Constructed only when the policy feature is wired (`SHIELD_MFA_POLICY_FEATURE`); without it the
|
|
29
|
+
* guard behaves exactly as before.
|
|
30
|
+
*/
|
|
31
|
+
import { HttpException } from '@nestjs/common';
|
|
32
|
+
import { type CoarseGrants, type MfaRequirement, type MfaRequirementSource, type Principal } from '@astram/core';
|
|
33
|
+
import { type BoundPrincipalClaims, type MfaRequirementState, type SessionTokenBinder } from '../request-auth/session-token-binder.js';
|
|
34
|
+
import type { SessionRestrictionStore } from './session-restriction-store.js';
|
|
35
|
+
/** The fail-closed answer when the requirement cannot be evaluated or recorded (503). */
|
|
36
|
+
export declare class MfaRequirementUnavailableError extends HttpException {
|
|
37
|
+
constructor();
|
|
38
|
+
}
|
|
39
|
+
/** What a sign-in binds (see {@link MfaRequirementState}). */
|
|
40
|
+
export type MfaSessionState = MfaRequirementState;
|
|
41
|
+
/** The facts one evaluation needs. */
|
|
42
|
+
export interface MfaSubjectFacts {
|
|
43
|
+
readonly principalId: string;
|
|
44
|
+
readonly isRoot: boolean;
|
|
45
|
+
/** The session's ACTIVE node (its claims' `tenantId`). */
|
|
46
|
+
readonly tenantId: string;
|
|
47
|
+
readonly roleIds: readonly string[];
|
|
48
|
+
readonly permissionKeys: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
/** The admin users list's 2FA state (auth capabilities R11.3). Never device data. */
|
|
51
|
+
export type MfaAdminStatus = 'enrolled' | 'required' | 'grace' | 'not_required';
|
|
52
|
+
/** One user of a page for {@link MfaRequirementService.statusesFor}. */
|
|
53
|
+
export interface MfaStatusSubject {
|
|
54
|
+
readonly principalId: string;
|
|
55
|
+
/** Strictly `true` for a root account: only platform-floor policies apply. */
|
|
56
|
+
readonly isRoot: boolean;
|
|
57
|
+
/** The user's ACTIVE membership nodes; empty ⇒ judged by the platform floor only. */
|
|
58
|
+
readonly nodeIds: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
/** Batched reads for a page (R11.3); the guard's MariaDB source implements them. */
|
|
61
|
+
export interface MfaStatusBatchSource {
|
|
62
|
+
/** The ids among `principalIds` with a confirmed factor. One query. */
|
|
63
|
+
confirmedFactorsOf(principalIds: readonly string[]): Promise<ReadonlySet<string>>;
|
|
64
|
+
/** Stored grace clocks for `principalIds` (absent ⇒ none). One query. */
|
|
65
|
+
requiredSinceOf(principalIds: readonly string[]): Promise<ReadonlyMap<string, number>>;
|
|
66
|
+
/** Each node with its ancestors, the node itself included. One query. */
|
|
67
|
+
scopeChainsOf(nodeIds: readonly string[]): Promise<ReadonlyMap<string, readonly string[]>>;
|
|
68
|
+
/**
|
|
69
|
+
* The principal's ACTIVE membership nodes (review item E2: the sign-in clock check). Optional;
|
|
70
|
+
* absent ⇒ a stored clock is never cleared at sign-in (only enrolment clears it).
|
|
71
|
+
*/
|
|
72
|
+
activeNodesOf?(principalId: string): Promise<readonly string[]>;
|
|
73
|
+
}
|
|
74
|
+
export interface MfaRequirementServiceDeps {
|
|
75
|
+
readonly source: MfaRequirementSource;
|
|
76
|
+
/** Batched page reads for {@link MfaRequirementService.statusesFor}; absent ⇒ it throws. */
|
|
77
|
+
readonly batch?: MfaStatusBatchSource | null;
|
|
78
|
+
/** Coarse grants + live roles for (principal, node): the session-facts projector. */
|
|
79
|
+
readonly loadGrants: (principalId: string, tenantId: string) => Promise<CoarseGrants | null>;
|
|
80
|
+
/** The node's current permission version (see {@link currentVersionReader}). */
|
|
81
|
+
readonly currentVersion: (tenantId: string) => Promise<number>;
|
|
82
|
+
readonly binder?: (Pick<SessionTokenBinder, 'updateMfaRequirement' | 'clearRestriction'> & Partial<Pick<SessionTokenBinder, 'restrictMfaEnrollment' | 'mfaRequirementOf'>>) | null;
|
|
83
|
+
readonly restrictions?: SessionRestrictionStore | null;
|
|
84
|
+
readonly now?: () => number;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The tenant's current permission version for the self-authenticating routes, read like gate 5
|
|
88
|
+
* reads it (ADR-0035): a Redis hit answers alone; a miss is filled once from the source of truth and
|
|
89
|
+
* written back (best-effort); a Redis ERROR throws {@link MfaRequirementUnavailableError} (deny,
|
|
90
|
+
* never served from the DB).
|
|
91
|
+
*/
|
|
92
|
+
export declare function currentVersionReader(cache: {
|
|
93
|
+
get(tenantId: string): Promise<number | null>;
|
|
94
|
+
raise(tenantId: string, version: number): Promise<void>;
|
|
95
|
+
}, fill: (tenantId: string) => Promise<number>): (tenantId: string) => Promise<number>;
|
|
96
|
+
export declare class MfaRequirementService {
|
|
97
|
+
private readonly deps;
|
|
98
|
+
private readonly now;
|
|
99
|
+
constructor(deps: MfaRequirementServiceDeps);
|
|
100
|
+
/**
|
|
101
|
+
* Evaluate the requirement and persist the clock the rule asks for (`start` keeps the EARLIER
|
|
102
|
+
* stored value, R2.4; `clear` deletes it). Throws on any store error (the caller fails closed).
|
|
103
|
+
*/
|
|
104
|
+
evaluateFor(subject: MfaSubjectFacts): Promise<MfaRequirement>;
|
|
105
|
+
/**
|
|
106
|
+
* The admin users list's `mfaStatus` for a page (auth capabilities R11.3, AC-17). READ ONLY: no
|
|
107
|
+
* clock is started or cleared (that stays a sign-in / request-path lifecycle write). Batched:
|
|
108
|
+
* one confirmed-factor read for the page; for the rest one scope-chain read, one policy read, and
|
|
109
|
+
* (only when a policy exists) one clock read. Grants are loaded per (user, node) ONLY where a
|
|
110
|
+
* `role` or `permission` policy could apply at that node (the documented per-row cost); `tenant`
|
|
111
|
+
* and platform policies need none. A confirmed device ⇒ `enrolled` whatever the policies; else the
|
|
112
|
+
* strictest answer over the user's active memberships (no membership ⇒ the platform floor only).
|
|
113
|
+
* Throws on any store error (the caller fails closed).
|
|
114
|
+
*/
|
|
115
|
+
statusesFor(subjects: readonly MfaStatusSubject[]): Promise<Map<string, MfaAdminStatus>>;
|
|
116
|
+
/**
|
|
117
|
+
* The state to bind at sign-in. `projected` is the login's own projection (reused: no second
|
|
118
|
+
* grants read); absent or `null` ⇒ the grants are loaded here. Throws on any failure.
|
|
119
|
+
*/
|
|
120
|
+
atLogin(principal: Principal, projected?: CoarseGrants | null): Promise<MfaSessionState>;
|
|
121
|
+
/**
|
|
122
|
+
* Review item E2: sign-in is a lifecycle event, so a stored clock that the CURRENT node does not
|
|
123
|
+
* need is checked against every active membership of the user (the users-page evaluation,
|
|
124
|
+
* {@link statusesFor}, plus the platform floor). Required nowhere ⇒ the clock is cleared, so a
|
|
125
|
+
* policy added later gives the full grace from that moment. Required (or in grace) anywhere ⇒
|
|
126
|
+
* kept, so switching nodes cannot dodge a deadline. Not wired, or a read error ⇒ kept (the safe
|
|
127
|
+
* direction; the sign-in itself already evaluated its own node).
|
|
128
|
+
*/
|
|
129
|
+
private clearClockIfRequiredNowhere;
|
|
130
|
+
/**
|
|
131
|
+
* The request-path recompute (R2.5). Returns the claims to enforce for THIS request: unchanged
|
|
132
|
+
* when nothing is due (NO DB query), else re-evaluated and recorded. `known.version` /
|
|
133
|
+
* `known.grants`: what gate 5 already has on a gated route. Throws
|
|
134
|
+
* {@link MfaRequirementUnavailableError} on any failure (the caller denies).
|
|
135
|
+
*/
|
|
136
|
+
refresh(accessToken: string, claims: BoundPrincipalClaims, known?: {
|
|
137
|
+
readonly version?: number | undefined;
|
|
138
|
+
readonly grants?: CoarseGrants | undefined;
|
|
139
|
+
}): Promise<BoundPrincipalClaims>;
|
|
140
|
+
/**
|
|
141
|
+
* After a refused write, bring the durable mandatory-2FA column back in line with the stored
|
|
142
|
+
* (authoritative) state when this recompute moved it the other way. Best-effort: the column is
|
|
143
|
+
* only read by a refresh rebuild, which is unstamped and so re-evaluated on its next request.
|
|
144
|
+
*/
|
|
145
|
+
private reconcileDurable;
|
|
146
|
+
/**
|
|
147
|
+
* A factor was just confirmed on this session (R2.3, AC-7): lift the restriction (Redis claims
|
|
148
|
+
* and the durable mandatory-2FA reason) and delete the grace clock (R2.4). Best-effort, like the
|
|
149
|
+
* re-enrol clear: a failure leaves the session restricted (the safe direction).
|
|
150
|
+
*/
|
|
151
|
+
onFactorConfirmed(claims: BoundPrincipalClaims): Promise<void>;
|
|
152
|
+
/**
|
|
153
|
+
* R2.7 defence in depth: a removal left no confirmed factor while a policy applies — restrict this
|
|
154
|
+
* session now (durable column, then the session-claims entry; see
|
|
155
|
+
* `SessionTokenBinder.restrictMfaEnrollment`) instead of waiting for the next sign-in. Throws when
|
|
156
|
+
* the restriction could not be recorded (the caller answers 503; the removal already happened, so
|
|
157
|
+
* the user retries into a restricted session or signs in again into one).
|
|
158
|
+
*/
|
|
159
|
+
restrictNow(claims: BoundPrincipalClaims): Promise<void>;
|
|
160
|
+
/**
|
|
161
|
+
* Whether a policy applies to the session's principal at its active node (R2.7): true for
|
|
162
|
+
* `enrolled` / `grace` / `required`. Reads only, never the clock. Throws on failure.
|
|
163
|
+
*/
|
|
164
|
+
requiresFactor(claims: BoundPrincipalClaims): Promise<boolean>;
|
|
165
|
+
/** The grants for (principal, node), or throw: an evaluation never runs on guessed grants. */
|
|
166
|
+
private grantsOf;
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=mfa-requirement-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mfa-requirement-service.d.ts","sourceRoot":"","sources":["../../src/session/mfa-requirement-service.ts"],"names":[],"mappings":"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"}
|