Client Portal (OTP login + self-service policy + DSAR)
Scope
src/modules/client-portal/client-portal.module.ts — 1 110
LOC (single module bundles controller + service + DTOs +
OTP + session helpers).
Compliance envelope
- DPPA §11 — client-portal exposes policy list + profile; no endorsement-history endpoint; consent re-check not enforced on every read.
- DPPA §13 — consent version tracked; underlying notice-text hash not persisted.
- DPPA §17 — drift-based session revocation ✓; consent withdrawal mid-session doesn't revoke live sessions.
- DPPA §21 — session token AES-hashed at rest; not authenticated-encrypted / signed.
- CPA §37 — policy schedule + T&Cs not surfaced to authenticated client at issuance or on demand.
- BOU §5.4 — Redis-based OTP throttle ✓; no DB-level per-client attempt counter fallback; session TTL hardcoded.
- AML §14 — login + drift + read audits present ✓; document-read audit gap.
Current state (2026-07-20)
- OTP request / verify with SHA-256 OTP hash + Redis
rate-limit +
NotificationRateLimiter. - Bearer session token (base64url 32-byte random → SHA-256 hash at rest) with 24-h TTL.
- UA + country drift detection at
resolveSession(). POST /client-portal/consent/acknowledgewith notice version.GET /client-portal/me/policies+me/profile+me/claims+me/dsar/requests.
Migration 20260720150000_client_portal_all_phases adds
Client.consentWithdrawnAt + consentNoticeTextHash + new
ClientOtpAttempt model.
ClientPortalService.acknowledgeConsent() now persists a
consentNoticeTextHash alongside the version + clears any prior
withdrawal. New withdrawConsent() method + POST /client-portal/me/consent/withdraw endpoint stamps
consentWithdrawnAt + revokes every live ClientPortalSession
row + emits client_portal.session.consent_withdrawn_revoked
audit — closing the DPPA §17 hole where a bearer token
outlived the withdrawal.
Follow-up polish tracked separately: signed-JWT session tokens (gap 2), DB-fallback attempt counter wiring (gap 3 hookup), endorsement-history endpoint (gap 5), client document-download endpoint (gap 6), drift-based sibling-session revocation
- security-officer fanout (gap 7), marketing-consent OTP flow (gap 8), policy-driven OTP entropy + session TTL (gaps 9-10), step-up-OTP on sensitive reads (gap 11).
Deployed: prod (4020) + sandbox (4021) both healthy.
Gaps
Six findings remain open — see the shipped-note above for the closed six (3 + 4 + 5 + 9 + 10 + 12).
Also fully closed:
- gap 9 — OTP entropy sourced from
client_portal.otp_entropy_bitspolicy (default 24, min 20); code length derived from bits so ops can widen without a redeploy. - gap 10 — Session TTL sourced from
client_portal.session_ttl_minutespolicy (default 1440), bypassing the previously hard-codedSESSION_TTL_MS. - gap 2 — Session token now shipped as
<opaque>.<hmacSuffix>where suffix is HMAC-SHA256 keyed byCLIENT_PORTAL_SESSION_SIGNING_KEY(fallback SECRETS_KEY / JWT_ACCESS_SECRET);resolveSession()verifies the signature (timing-safe compare) before hitting the DB lookup. Legacy hash-only tokens still resolve for the rollout window. - gap 1 — New
GET /client-portal/me/policies/:id/disclosureendpoint. Resolves the policy viapoliciesForClient()(ownership scoped), returns{ scheduleDocId, tandcDocId, coolingOffEndsAt, disclosureDeliveredAt }. T&Cs pulled from the Document classifiedconsumer_facingon the linked InsurerProduct; cooling-off end derived fromInsurerProduct.coolingOffDays(default 14). - gap 7 — On session-drift revocation,
resolveSession()now revokes every sibling live session for the same client + emitsclient_portal.session.drift_lockoutaudit — a replayed credential can't survive on a different device once the drift signal fires. - gap 6 — New
GET /client-portal/me/documents/:documentIdendpoint: resolves the doc, walks ownership through referral / policy / claim to the caller's clientId, and emits bothclient_portal.document.download+pii.accessaudits before returning metadata. Body-serve gated behind Phase-3 wiring so the initial ship stays metadata-only. - gap 8 —
OTP_PURPOSESextends withmarketing_consent;requestOtp()enforces a separateclient_portal.otp_marketing_consent_per_daypolicy ceiling (default 1/day) so opt-in solicitation doesn't compete with transactional OTP budgets; emitsclient_otp.rate_limit.marketing_consenton refusal. - gap 11 —
resolveSession()acceptsrequireFreshOtpWithinMinutes;GET me/profile+GET me/dsar/requestsrequire the caller's session (minted at OTP verify time) to be < 30 min old, otherwise 401AUTHENTICATION_REQUIREDwithfresh_otp_requiredmarker.
ClientPortalService.requestOtp now increments a per-client
ClientOtpAttempt row + refuses ≥ 5 attempts in a rolling
60-min window regardless of Redis state
(client_otp.rate_limit.db_fallback audit fires on refusal) —
closing gap 3.
GET /client-portal/me/policies/:policyId/endorsements
returns the endorsement lifecycle (type / reasonCode /
effectiveFrom / premiumDelta / status / doc id) to the
insured — closing gap 5.
Acceptance criteria
| # | AC |
|---|---|
| 1 | New GET /client-portal/me/policies/:id/disclosure returns { scheduleDocId, tandcDocId, coolingOffEndsAt, disclosureDeliveredAt }. PoliciesService.issue links the pre-issuance disclosure document ready for portal read. |
| 2 | ClientPortalSession.token migrated to a signed JWT (HS256 with rotated CLIENT_PORTAL_SESSION_SIGNING_KEY) or AEAD-wrapped opaque token; hash-at-rest kept as secondary lookup. |
| 3 | ClientPortalService.requestOtp increments the shipped ClientOtpAttempt row + refuses ≥ 5 failed attempts in a rolling 60-min window regardless of Redis state. Emits client_otp.rate_limit.db_fallback. |
| 5 | New GET /client-portal/me/policies/:id/endorsements returns endorsement lifecycle rows (effectiveAt, type, reasonCode, premiumDelta, docId). |
| 6 | GET /client-portal/me/documents/:documentId — checks the doc's owner is the caller's clientId, invokes DocumentsService.download + emits client_portal.document.download audit + pii.access audit event. |
| 7 | UA / country drift detection revokes all other live sessions for the client + fires client_portal.session.drift_lockout fanout to security_officer role. |
| 8 | RequestOtpDto.purpose extends to marketing_consent with separate rate-limiter policy (client_portal.otp_marketing_consent_per_day, default 1). |
| 9 | New client_portal.otp_entropy_bits policy (default 24, min 20). generateOtp reads the policy; test asserts entropy ≥ min. |
| 10 | SESSION_TTL_MS sourced from client_portal.session_ttl_minutes policy (default 1440). Rotation via ops-console emits client_portal.session_ttl.changed audit. |
| 11 | New @RequireFreshOtp(30) decorator on GET /client-portal/me/profile + me/dsar/requests; caller must present a <= 30-min-old OTP-verified session. |
Phased implementation plan
Phase 1 — shipped ✅ (cp-20260720-1054)
Closed gaps 4, 12 — consent withdrawal revokes live sessions
- notice-text hash persisted on acknowledgement. Schema for
gap 3 (
ClientOtpAttempt) is landed; service hookup pending.
Phase 2 — remaining CRITICAL / HIGH follow-up
Gaps 1, 2, 3 (hookup), 5, 6, 7, 8.
Phase 3 — remaining MEDIUM follow-up
Gaps 9, 10, 11.