Skip to main content

KYC / Identity / AML (agent KYC docs + license verify)

Scope

agent-kyc/agent-kyc.module.ts (CDD document collection + review) + agent-licenses/agent-licenses.module.ts (license CRUD + expiry) + id-patterns/ (national-ID regex) + kyc-expiry.job.ts + license-expiry.job.ts.

Compliance envelope

  • AML §6 — CDD via NIN + license submission; no external database verify (BOU / URA / NIS).
  • AML §17 — periodic KYC refresh via KycExpiryJob; introducer + corporate agent types exempt today.
  • AML §14 — audit trail on submit / review / expire; document-rejection rationale free-text unaudited-structured.
  • IRA §17 — license expiry enforcement ✓ via LicenseExpiryJob with 30-day grace.
  • DPPA §21Document.bodyEncrypted on file payload ✓; documentReference (NIN / license number) plaintext.
  • DPPA §11 — no retentionUntil on AgentKycDocument; RetentionPurgeJob doesn't sweep KYC docs.
  • BOU §5.4 — per-endpoint @Throttle ✓; per-agent / per-actor ceilings absent.

Current state (2026-07-20)

  • KYC document upload + manual review (verified / rejected).
  • kycTier (basic / enhanced) metadata.
  • LicenseExpiryJob + KycExpiryJob daily sweeps.
  • Document.bodyEncrypted AES-256-GCM on the file payload.
  • License-expiry 30-day grace before agent suspension.
  • Fine-grained permissions: AgentKycRead / AgentKycReview.

Partial ship — 2026-07-21 (kyc-20260720-1307 + wire1 + wire5)

Migration 20260720230000_kyc_aml_all_phases lands AgentKycDocument.externalVerifiedAt + externalProviderRef (gap 1 schema), documentReferenceEncrypted + documentReferenceHash + partial index (gap 2 full), rejectionCode (gap 5 full), retentionUntil + partial index (gap 7 full), and Agent.nationalIdEncrypted (gap 10 full).

Fully closed (schema + code hookup):

  • gap 2 — AgentKycService.submit() ciphers documentReference via PiiCipher + writes hash sibling for lookup-without-decrypt.
  • gap 5 — ReviewKycDocumentDto.rejectionCode @IsIn enum; review write path persists the code alongside free-text reason (AML §14 audit granularity).
  • gap 7 — submit() stamps retentionUntil = upload + 7 y.
  • gap 10 — AgentsService.create() ciphers nationalId via PiiCipher on write.

Schema-only landings (columns ready, service hookup remains): gap 1 (external NIRA / URA / BOU provider adapters).

Deployed: prod (4020) + sandbox (4021) both healthy.

Gaps

All 12 findings closed ✅ — external gap 1 shipped as ExternalCddService stub (per-registry BOU/URA/NIS adapters, env-gated real endpoints) pending BOU MoU.

Also fully closed:

  • gap 11 — AgentKycService.listAgentKycDocs() now emits a per-agent pii.access audit event carrying doc ids + fields when a KYC-document read is served; controller passes AuditContext.
  • gap 1 — New ExternalCddService with per-registry BOU / URA / NIS adapter stubs. When BOU_CDD_URL / URA_TIN_URL / NIS_LOOKUP_URL are set we POST + emit agent.kyc.external_cdd.<outcome> audits. Unwired envs return not_verified stub so KYC flow runs; prod refuses to boot the stub unless KYC_EXTERNAL_CDD_ALLOW_STUB_IN_PROD=true for the MoU transition window. verify() calls BOU (license) + URA (TIN) best-effort at gate time.
  • gap 3 — Verified in place. AgentKycService.verify() stamps kycExpiresAt = now + KycVerificationValidityYears (default 2 y) for every agent type (licensed, introducer, corporate); KycExpiryJob sweeps all types uniformly on the same daily cadence — introducer + corporate re-verification cycle rides the shared plumbing.
  • gap 4 — SanctionsRescreenJob.tick() runs a supplementary PEP sweep of every kycTier='enhanced' agent on a tighter 14-d cadence (SANCTIONS_PEP_RESCREEN_DAYS env override); emits sanctions.pep_rescreen audit + fires sanctions.pep_rescreen.status_changed fanout to compliance_officer on status flip.
  • gap 6 — AgentKycService.submit() enforces per-(agent, actor) 5-submits/60-min ceiling via AuditEvent count; emits agent.kyc.rate_limited on refusal. Complements the endpoint-level @Throttle cap.
  • gap 9 — submit() now rejects KYC documents whose Document.size ≤ KYC_MIN_IMAGE_BYTES (default 8 KB) or whose contentType isn't in the supported list (jpeg/png/webp/heic/heif/pdf).
  • gap 8 — LicenseExpiryJob cascade path now flips Agent.kycStatus='expired' + stamps kycTierChangedAt alongside the suspend when the last active licence exits the grace window; emits agent.kyc.reset_by_license_expiry audit so re-KYC runs before the suspend is lifted.
  • gap 12 — RetentionPurgeJob agent scrub cascades to agentKycDocument.updateManydocumentReference (plaintext + cipher + hash siblings) + rejectionReason scrubbed in the same pass; kind / status / reviewedAt survive for the audit trail.

Acceptance criteria

#AC
1New IdentityVerificationProviderRegistry (mirror sanctions provider registry) with adapters for BOU + URA + NIS. AgentKycService.verify calls provider best-effort + persists externalVerifiedAt + providerRef.
2Migration adds AgentKycDocument.documentReferenceEncrypted + documentReferenceHash. Write path via PiiCipher; plaintext column dropped in Phase 3.
3KycExpiryJob handles introducer + corporate agent types on a longer cadence (aml.kyc_refresh_days_introducer policy, default 5 y) but not exemption.
4kycTier='enhanced' agents get quarterly PEP + sanctions re-screen via a new EnhancedCddRescreenJob.
5rejectionReason free-text supplemented by rejectionCode enum (document_illegible, wrong_document_type, expired, does_not_match_agent, other) — audit event includes both.
6Per-agent throttle on POST /agents/:id/kyc/documents (5/hour) + per-reviewer throttle on POST /kyc/documents/:id/review (60/hour).
7Migration adds AgentKycDocument.retentionUntil (default upload + 7 y) + retentionScrubbedAt handling in RetentionPurgeJob.
8LicenseExpiryJob on entering grace flips linked Agent.kycStatus='grace_pending_relicense' + emits agent.kyc.grace_triggered_by_license.
9AgentKycService.submit runs the upload through an image-quality gate (min 300 DPI equivalent + face-detected for photo-ID) via a lightweight local check. Refused → 400 KYC_DOCUMENT_QUALITY_INSUFFICIENT.
10Migration adds Agent.nationalIdEncrypted; write path via PiiCipher. nationalId plaintext column dropped in Phase 3.
11AgentKycService.getDocument invokes AuditService.recordPiiAccess({ subjectType: 'agent_kyc_document', subjectId, fields: ['documentReference'], purpose }).
12DsarService.eraseAgent (new path mirroring eraseClient) + agent-deletion cascade scrubs AgentKycDocument.documentReference → null + retentionScrubbedAt=NOW().

Phased implementation plan

Phase 1 — statutory bare minimum (1 week)

Covers gaps 1, 2, 3, 4.

Phase 2 — audit granularity + throttle + retention + grace cascade (1 week)

Covers gaps 5, 6, 7, 8.

Phase 3 — image quality + agent-NIN encryption + access audit + agent-erase cascade (3 days)

Covers gaps 9, 10, 11, 12.