# 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: ```typescript // 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](../encryption/encryptedFieldsMaps.ts) | Builds per-tier `encryptedFieldsMap`; `buildEncryptedFieldsMaps(deks, 'level1' | 'level2')` | | [roleClients.ts](../encryption/roleClients.ts) | Two lazy MongoClient pools; `getDbForRole(role, hasToken)` selects pool | | [createAtlasRoles.ts](../setup/createAtlasRoles.ts) | Atlas Admin API automation: roles + DB users | | [customerAgreement.model.ts](../../modules/customer/models/customerAgreement.model.ts) | `isSensitiveDecrypted()` helper | | [customerAgreement.service.ts](../../modules/customer/services/customerAgreement.service.ts) | `resolveDb()`, `buildResponse()`, `maybeAudit()` | | [cardTransaction.service.ts](../../modules/transactions/services/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.*