Skip to content
Antonio Membrides Espinosa edited this page Jul 24, 2026 · 4 revisions

PII Field Protection Strategy — Option C Simplified (v2)

Status: Implemented in v2
PCI DSS coverage: Req 3 (encryption-in-use), Req 7 (access control), Req 10 (audit logging)


1. Problem Statement

LeafyBank's PCI DSS payment investigation workflow requires that:

  • L1 analysts can search customers by QE-encrypted customerAgreementReference and partyEmailAddress
  • L2 investigators and security auditors can also decrypt sensitive PII (residential address, government ID, raw gateway payload)
  • No party — including a MongoDB Atlas admin — can read sensitive fields without the correct DEK tier
  • The access-control mechanism satisfies PCI DSS Req 7's least-privilege and need-to-know requirements

2. Options Evaluated

Option A: Split Collections (Sensitive + Non-Sensitive)

Two MongoDB collections per domain:
customerAgreementProcedure (non-PII) + customerAgreementProcedureSensitive (PII)
cardTransactionLog (non-PII) + cardTransactionLogSensitive (PII)

Atlas RBAC: pci_level1_role has find only on non-sensitive collections. pci_level2_role has find on both.

Dimension Assessment
Schema complexity −2 extra collections; cross-collection joins for every query
BIAN compliance Non-BIAN: BIAN SD-53 and SD-254 don't define a *Sensitive sub-domain
Encryption depth Atlas RBAC alone — no encryption-in-use; Req 3 not satisfied by RBAC
Maintainability Every query that needs both records requires a JOIN; migration doubles write paths
PCI DSS alignment Partial — Req 7 via RBAC, but Req 3 requires encryption

Verdict: Structurally incorrect for PCI DSS Req 3. Rejected.


Option B: Application-Layer Projection Only

Single collection per domain, all fields stored in plaintext (encrypted at rest by Atlas).
Application code strips sensitive fields from API responses based on JWT role.

Dimension Assessment
Simplicity Highest — no QE, no connection pools
Encryption depth None in-use; violates Req 3 if Atlas storage encryption is the only layer
Complete Mediation Violated — a DB admin or compromised API can bypass projection
Audit evidence Hard to demonstrate field-level controls to a QSA

Verdict: Violates PCI DSS Req 3 and the Complete Mediation principle (security-principles.md §1.3). Rejected for a compliance demo.


Option C Simplified (Selected) ✓

Single collection per domain. Sensitive fields co-located with non-sensitive fields.
Three enforcement layers applied in depth:

Layer 1 (KMS IAM)   → AWS KMS key policy restricts who can call Decrypt for DEK-sensitive keys
Layer 2 (QE engine) → MongoDB driver enforces encryptedFieldsMap; fields not in map = Binary
Layer 3 (App API)   → Service layer detects Binary fields and omits them from JSON responses
Dimension Assessment
Schema BIAN-compliant; 7 core QE-encrypted collections (party, cardTransactionLog, customerAgreementProcedure, paymentCardManagement, customerAuthenticationAssessment, payoutAccountArrangement, paymentExecutionProcedure) plus the module-owned cardIssuerVault (full PAN + service code, QE:equality) within a ~35-collection database
Encryption QE:equality for searchable fields; QE:none for sensitive PII (random cipher, non-searchable)
RBAC Atlas collection-level + QE tier differentiated by connection pool
Audit logging Two distinct DB users (pci_l1, pci_l2) → Atlas audit log shows which tier accessed what
Complete Mediation Satisfied — control is at the cryptographic layer, not just at the application layer
PCI DSS Req 3 Satisfied by QE encryption-in-use
PCI DSS Req 7 Satisfied by Atlas custom roles + QE tier
PCI DSS Req 10 Satisfied by separate DB credentials per tier

3. Architecture

3.1 QE Field Classification

Field Collection QE Type DEK Searchable
partyEmailAddress party equality DEK-party-email Yes
partyMobilePhoneNumber party equality DEK-party-phone Yes
partyPostalAddress party none DEK-party-address No
partyDateOfBirth party none DEK-party-dob No
customerAgreementReference customerAgreementProcedure equality DEK-customer-account-ref Yes
customerAgreementResidentialAddress customerAgreementProcedure none DEK-customer-address No
governmentIdentificationReference customerAgreementProcedure none DEK-customer-gov-id No
customerAgreementRiskNotes customerAgreementProcedure none DEK-customer-risk-notes No
cardTransactionAccountReference cardTransactionLog equality DEK-tx-account-ref Yes
rawGatewayPayload cardTransactionLog none DEK-tx-raw-payload No
processorTransactionMetadata cardTransactionLog none DEK-tx-processor-meta No
paymentCardExpirationDate paymentCardManagement none DEK-card-expiry No
customerAuthenticationEmailAddress customerAuthenticationAssessment equality DEK-auth-email Yes
payoutAccountIban payoutAccountArrangement none DEK-payout-iban No
payoutAccountRoutingNumber payoutAccountArrangement none DEK-payout-routing No
destinationIban paymentExecutionProcedure none DEK-exec-dest-iban No
paymentCardNumber (full PAN) cardIssuerVault (module-owned) equality DEK-vault-pan Yes
cardServiceCode cardIssuerVault (module-owned) equality DEK-vault-service-code Yes

CVV, PAN and the issuer vault. The CVV is Sensitive Authentication Data: it is never persisted (not in any collection, in cleartext or ciphertext). The built-in issuer derives it per card on demand (HMAC-SHA256 under the issuer key CVK) for validation and reveal. The full PAN is Cardholder Data: the PSP core never stores it (core keeps only token + BIN + last4, and the masked PAN is derived on the fly, no longer persisted). When the built-in card-issuer module is active, the full PAN and cardServiceCode live only in the module-owned cardIssuerVault (issuer CDE) with QE:equality. The full PAN (like the IBAN, and the card expiry in card detail) is hidden by default and revealed on demand behind an eye icon: ephemeral, audited (card.pan.revealed / account.iban.revealed), gated to operations_officer (direct) or the card owner (via the provider flow), with step-up MFA/SCA in production.

paymentCardExpirationDate is QE:none (retrieval-only, not searchable). The bank-data fields (payoutAccountIban, payoutAccountRoutingNumber, destinationIban) and party DOB/address are protected under GDPR Art. 32 / PSD2, not PCI. All QE:none fields are Level 2 only.

3.2 QE Tier (Connection Pool) Architecture

                    ┌──────────────────────────────────────────────┐
                    │          Application Layer (Node.js)         │
                    │                                              │
   L1 request  ──►  │  getDbForRole(role, hasToken)                │
                    │    canReadSensitive(role, token) = false     │
                    │         │                                    │
                    │         ▼                                    │
                    │  getL1QEClient()  ──► L1 MongoClient         │
                    │    encryptedFieldsMap: equality only         │
                    │    QE:none fields → Binary (unreadable)      │
                    │                                              │
   L2 request  ──►  │  getDbForRole(role, hasToken)                │
                    │    canReadSensitive(role, token) = true      │
                    │         │                                    │
                    │         ▼                                    │
                    │  getL2QEClient()  ──► L2 MongoClient         │
                    │    encryptedFieldsMap: equality + QE:none    │
                    │    All fields auto-decrypted by driver       │
                    └──────────────────────────────────────────────┘
                              │                    │
                    MONGODB_URI_LEVEL1   MONGODB_URI_LEVEL2
                    (pci_l1 DB user)     (pci_l2 DB user)
                              │                    │
                    ┌──────────────────────────────────────────────┐
                    │              MongoDB Atlas                   │
                    │                                              │
                    │  pci_level1_role: FIND on all collections    │
                    │  pci_level2_role: FIND+UPDATE+INSERT         │
                    │                                              │
                    │  Documents: sensitive fields stored as QE    │
                    │  Binary ciphertext (BSON subtype 6)          │
                    └──────────────────────────────────────────────┘

3.3 Binary Field Detection

When the L1 client reads a document, QE:none fields are returned as BSON.Binary (sub_type=6, not in L1 encryptedFieldsMap). The service layer uses isSensitiveDecrypted() to detect this:

// Returns false if field is MongoDB Binary (not decrypted by driver)
export function isSensitiveDecrypted(field: unknown): boolean {
  if (field === undefined || field === null) return false;
  if (typeof field === 'object' && field !== null &&
      'sub_type' in field && 'buffer' in field) return false;
  return true;
}

This is the Layer 3 guard: even if a Binary value were somehow serialized to JSON, it would be a raw buffer — not PII.


4. Setup Automation (v2)

npm run setup:db now runs createAtlasRoles.ts as Step 1, which:

  1. Calls Atlas Admin API POST /api/atlas/v2/groups/{projectId}/customDBRoles to create pci_level1_role and pci_level2_role
  2. Calls POST /api/atlas/v2/groups/{projectId}/databaseUsers to create pci_l1 and pci_l2 DB users
  3. Both use HTTP Digest Auth (RFC 7616 MD5) with ATLAS_PUBLIC_KEY / ATLAS_PRIVATE_KEY
  4. Gracefully skips (with a warning) if Atlas API credentials are not set

Required env vars for Atlas automation: ATLAS_PUBLIC_KEY, ATLAS_PRIVATE_KEY, ATLAS_PROJECT_ID, ATLAS_DB_USER_LEVEL1, ATLAS_DB_USER_LEVEL1_PASSWORD, ATLAS_DB_USER_LEVEL2, ATLAS_DB_USER_LEVEL2_PASSWORD.

See backend/src/vendors/setup/env.example for the complete variable reference.


5. PCI DSS Requirement Mapping

PCI DSS Req Requirement How Option C Satisfies It
Req 3 Protect stored cardholder data QE encryption-in-use: sensitive fields are encrypted at the MongoDB driver level before write; never stored as plaintext
Req 7 Restrict access to system components Atlas custom roles (pci_level1_role, pci_level2_role) grant FIND at collection level; QE tier restricts readable fields within each document
Req 8 Identify users and authenticate access Two distinct DB credentials per QE tier; separate JWT roles (level1_analyst, level2_investigator, security_auditor) drive pool selection
Req 10 Log and monitor all access Two DB credentials produce distinct Atlas audit log entries; appendAuditEvent() records every sensitive-field access with caseId, role, and field names
Req 3.4 Render PAN unreadable PSP core never stores the full PAN (token + BIN + last4 only; masked PAN derived, not persisted); when the built-in issuer is active the full PAN is stored only in the module-owned cardIssuerVault under QE (unreadable to the server), revealed on demand and audited; rawGatewayPayload (which may contain full PAN in gateway responses) encrypted via QE:none
Req 3.2 Never store SAD CVV / PIN never stored; the per-card CVV is derived (HMAC-SHA256 under the issuer CVK) on demand and never persisted

6. Key Files

File Role
encryptedFieldsMaps.ts Builds per-tier encryptedFieldsMap; `buildEncryptedFieldsMaps(deks, 'level1'
roleClients.ts Two lazy MongoClient pools; getDbForRole(role, hasToken) selects pool
createAtlasRoles.ts Atlas Admin API automation: roles + DB users
customerAgreement.model.ts isSensitiveDecrypted() helper
customerAgreement.service.ts resolveDb(), buildResponse(), maybeAudit()
cardTransaction.service.ts getTransactionById() with Binary detection

Beneficial owner PII (GDPR minimization)

Beneficial-owner PII (name, DOB, address, gov ID) lives in the party record (QE tiers), NOT duplicated in the merchantBeneficialOwners embed on merchantAgreementProcedure. The embed holds only the party FK + role + numeric ownership/control metadata. Erasure and PII edits happen at the single party surface (one owner surface, no duplicate audit path).

Added 2026-07-24.

Clone this wiki locally