@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.
Files changed (129) hide show
  1. package/dist/auth/account.controller.d.ts +3 -1
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +79 -6
  4. package/dist/auth/admin-signin-controls.d.ts +80 -0
  5. package/dist/auth/admin-signin-controls.d.ts.map +1 -0
  6. package/dist/auth/admin-signin-controls.js +91 -0
  7. package/dist/auth/auth-audit.d.ts +59 -0
  8. package/dist/auth/auth-audit.d.ts.map +1 -0
  9. package/dist/auth/auth-audit.js +93 -0
  10. package/dist/auth/auth.controller.d.ts +53 -6
  11. package/dist/auth/auth.controller.d.ts.map +1 -1
  12. package/dist/auth/auth.controller.js +330 -44
  13. package/dist/auth/auth.module.d.ts +49 -0
  14. package/dist/auth/auth.module.d.ts.map +1 -1
  15. package/dist/auth/auth.module.js +149 -4
  16. package/dist/auth/captcha-gate.d.ts +103 -0
  17. package/dist/auth/captcha-gate.d.ts.map +1 -0
  18. package/dist/auth/captcha-gate.js +245 -0
  19. package/dist/auth/dto.d.ts +22 -1
  20. package/dist/auth/dto.d.ts.map +1 -1
  21. package/dist/auth/login-name-count-boot.d.ts +24 -0
  22. package/dist/auth/login-name-count-boot.d.ts.map +1 -0
  23. package/dist/auth/login-name-count-boot.js +47 -0
  24. package/dist/auth/logout-proof.d.ts +55 -0
  25. package/dist/auth/logout-proof.d.ts.map +1 -0
  26. package/dist/auth/logout-proof.js +127 -0
  27. package/dist/auth/pending-mfa-store.d.ts +5 -0
  28. package/dist/auth/pending-mfa-store.d.ts.map +1 -1
  29. package/dist/auth/pending-mfa-store.js +2 -0
  30. package/dist/auth/ports/audit-sink.d.ts +5 -2
  31. package/dist/auth/ports/audit-sink.d.ts.map +1 -1
  32. package/dist/auth/ports/mfa-device-store.d.ts +7 -0
  33. package/dist/auth/ports/mfa-device-store.d.ts.map +1 -1
  34. package/dist/auth/profile-resolver.d.ts +6 -0
  35. package/dist/auth/profile-resolver.d.ts.map +1 -1
  36. package/dist/auth/rate-limit/attempt-limiter.d.ts +6 -0
  37. package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -1
  38. package/dist/auth/rate-limit/attempt-limiter.js +14 -0
  39. package/dist/auth/tokens.d.ts +59 -0
  40. package/dist/auth/tokens.d.ts.map +1 -1
  41. package/dist/auth/tokens.js +61 -1
  42. package/dist/authentication/credential-provisioner.d.ts +7 -0
  43. package/dist/authentication/credential-provisioner.d.ts.map +1 -1
  44. package/dist/authentication/mariadb-credential-provisioner.d.ts +1 -0
  45. package/dist/authentication/mariadb-credential-provisioner.d.ts.map +1 -1
  46. package/dist/authentication/mariadb-credential-provisioner.js +7 -0
  47. package/dist/authentication/mariadb-credential-resolver.d.ts +23 -2
  48. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  49. package/dist/authentication/mariadb-credential-resolver.js +45 -1
  50. package/dist/authentication/mariadb-mfa-device-store.d.ts +7 -0
  51. package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
  52. package/dist/authentication/mariadb-mfa-device-store.js +32 -0
  53. package/dist/authentication/mariadb-mfa-requirement-source.d.ts +55 -0
  54. package/dist/authentication/mariadb-mfa-requirement-source.d.ts.map +1 -0
  55. package/dist/authentication/mariadb-mfa-requirement-source.js +164 -0
  56. package/dist/authentication/mariadb-mfa-resetter.d.ts +42 -0
  57. package/dist/authentication/mariadb-mfa-resetter.d.ts.map +1 -0
  58. package/dist/authentication/mariadb-mfa-resetter.js +59 -0
  59. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  60. package/dist/authentication/mariadb-profile-writer.js +5 -0
  61. package/dist/authentication/sign-in-with.d.ts +14 -0
  62. package/dist/authentication/sign-in-with.d.ts.map +1 -0
  63. package/dist/authentication/sign-in-with.js +36 -0
  64. package/dist/authz/core-access.d.ts +11 -0
  65. package/dist/authz/core-access.d.ts.map +1 -0
  66. package/dist/authz/core-access.js +14 -0
  67. package/dist/authz/list-scope-sql.d.ts.map +1 -1
  68. package/dist/authz/list-scope-sql.js +15 -5
  69. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  70. package/dist/authz/wire-record-authorization.js +21 -3
  71. package/dist/captcha/captcha.providers.d.ts +18 -0
  72. package/dist/captcha/captcha.providers.d.ts.map +1 -0
  73. package/dist/captcha/captcha.providers.js +78 -0
  74. package/dist/captcha/recaptcha-verifier.d.ts +20 -0
  75. package/dist/captcha/recaptcha-verifier.d.ts.map +1 -0
  76. package/dist/captcha/recaptcha-verifier.js +48 -0
  77. package/dist/captcha/siteverify.d.ts +47 -0
  78. package/dist/captcha/siteverify.d.ts.map +1 -0
  79. package/dist/captcha/siteverify.js +98 -0
  80. package/dist/captcha/turnstile-verifier.d.ts +18 -0
  81. package/dist/captcha/turnstile-verifier.d.ts.map +1 -0
  82. package/dist/captcha/turnstile-verifier.js +41 -0
  83. package/dist/config/captcha-config.d.ts +47 -0
  84. package/dist/config/captcha-config.d.ts.map +1 -0
  85. package/dist/config/captcha-config.js +163 -0
  86. package/dist/config/reserved-principal-ids.d.ts +15 -0
  87. package/dist/config/reserved-principal-ids.d.ts.map +1 -1
  88. package/dist/config/reserved-principal-ids.js +20 -3
  89. package/dist/enforcement/global-enforcement.guard.d.ts +9 -1
  90. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  91. package/dist/enforcement/global-enforcement.guard.js +52 -10
  92. package/dist/enforcement/reenroll-allowlist.d.ts +5 -0
  93. package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -1
  94. package/dist/enforcement/reenroll-allowlist.js +5 -0
  95. package/dist/identity/auth-facts.controller.d.ts +31 -4
  96. package/dist/identity/auth-facts.controller.d.ts.map +1 -1
  97. package/dist/identity/auth-facts.controller.js +46 -11
  98. package/dist/identity/identity-actions.d.ts +2 -2
  99. package/dist/identity/identity-actions.d.ts.map +1 -1
  100. package/dist/identity/module-registry.bridge.d.ts.map +1 -1
  101. package/dist/identity/module-registry.bridge.js +3 -6
  102. package/dist/identity/permission-facts.adapter.d.ts +22 -0
  103. package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
  104. package/dist/identity/permission-facts.adapter.js +26 -1
  105. package/dist/index.d.ts +9 -2
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +36 -10
  108. package/dist/internal.d.ts +27 -0
  109. package/dist/internal.d.ts.map +1 -1
  110. package/dist/internal.js +36 -1
  111. package/dist/module/shield.module.d.ts +27 -0
  112. package/dist/module/shield.module.d.ts.map +1 -1
  113. package/dist/module/shield.module.js +10 -0
  114. package/dist/request-auth/request-principal.d.ts +8 -1
  115. package/dist/request-auth/request-principal.d.ts.map +1 -1
  116. package/dist/request-auth/request-principal.js +11 -4
  117. package/dist/request-auth/session-token-binder.d.ts +82 -0
  118. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  119. package/dist/request-auth/session-token-binder.js +203 -1
  120. package/dist/session/mfa-requirement-service.d.ts +168 -0
  121. package/dist/session/mfa-requirement-service.d.ts.map +1 -0
  122. package/dist/session/mfa-requirement-service.js +461 -0
  123. package/dist/session/session-restriction-store.d.ts +36 -0
  124. package/dist/session/session-restriction-store.d.ts.map +1 -1
  125. package/dist/session/session-restriction-store.js +57 -6
  126. package/dist/session/session-restriction.d.ts +22 -0
  127. package/dist/session/session-restriction.d.ts.map +1 -0
  128. package/dist/session/session-restriction.js +23 -0
  129. 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
- return mustReenroll === parsed.mustReenroll ? parsed : { ...parsed, mustReenroll };
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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"}