-
Notifications
You must be signed in to change notification settings - Fork 0
PII
Status: Implemented in v2
PCI DSS coverage: Req 3 (encryption-in-use), Req 7 (access control), Req 10 (audit logging)
LeafyBank's PCI DSS payment investigation workflow requires that:
- L1 analysts can search customers by QE-encrypted
customerAgreementReferenceandpartyEmailAddress - 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
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.
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.
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 |
| 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-issuermodule is active, the full PAN andcardServiceCodelive only in the module-ownedcardIssuerVault(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 tooperations_officer(direct) or the card owner (via the provider flow), with step-up MFA/SCA in production.
paymentCardExpirationDateis QE:none (retrieval-only, not searchable). The bank-data fields (payoutAccountIban,payoutAccountRoutingNumber,destinationIban) andpartyDOB/address are protected under GDPR Art. 32 / PSD2, not PCI. All QE:none fields are Level 2 only.
┌──────────────────────────────────────────────┐
│ 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) │
└──────────────────────────────────────────────┘
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.
npm run setup:db now runs createAtlasRoles.ts as Step 1, which:
- Calls Atlas Admin API
POST /api/atlas/v2/groups/{projectId}/customDBRolesto createpci_level1_roleandpci_level2_role - Calls
POST /api/atlas/v2/groups/{projectId}/databaseUsersto createpci_l1andpci_l2DB users - Both use HTTP Digest Auth (RFC 7616 MD5) with
ATLAS_PUBLIC_KEY/ATLAS_PRIVATE_KEY - 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.
| 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 |
| 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 (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.