Data inventory — ZevID¶
Every distinct data field ZevID collects, derives, or stores. Each row is a verifiable claim — when a field lives at a specific column in a specific schema file, the cell points at it.
Columns are fixed
Don't add columns without proposing the change in TEMPLATE.md at the repo root (visible to engineers on GitHub). The DPO reads N products with the same table shape — adding columns to one breaks that.
Schema source of truth
All references are to files under zevid-backend/src/common/database/schema/. Retention specifics live in retention.md.
Identity data¶
The "who is this user" fields. Owned by ZevID; every other Zev product reads these via GET /v1/internal/users/:accountId rather than duplicating.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
accountId |
UUID | Server-generated on signup | Contract — necessary to provide the account | accounts.id |
Account lifetime | Single cross-ecosystem identifier; every Zev product addresses users by this |
publicId |
string (21 chars) | Server-generated on signup | Contract | accounts.public_id |
Account lifetime | Human-friendly short id (shown in admin UI, support tickets); never used for auth |
email |
string | User input at signup | Contract — login identifier | accounts.email (unique) |
Account lifetime | UNIQUE constraint |
passwordHash |
string | bcrypt hash of user-supplied password | Contract | accounts.password_hash |
Account lifetime | bcrypt cost 12 (auth.service.ts:37 BCRYPT_ROUNDS); plaintext is never persisted |
firstName |
string | User input | Contract | accounts.first_name |
Account lifetime | Validated via @IsSafeName() (zevid-backend/src/common/validators/safe-name.validator.ts) to block spam-laundering attack patterns |
lastName |
string | User input | Contract | accounts.last_name |
Account lifetime | Same validation as firstName |
phone (legacy column) |
string | Synced from primary verified phone | Contract | accounts.phone |
Mirrors the primary verified phone in account_phones; auto-cleared when last verified phone is removed |
Older code paths (KYC, login alerts) read this column; new code paths use account_phones |
displayPicture |
URL | User upload (R2-hosted) | Consent (optional) | accounts.display_picture |
Account lifetime | Object stored in Cloudflare R2 bucket; see third-parties.md |
isEmailVerified |
boolean | Set after email-OTP confirmation | Contract | accounts.is_email_verified |
Account lifetime | Default false; flipped true after first successful OTP |
isBanned / banReason |
bool + text | Admin action via admin panel | Legitimate interest (fraud prevention) | accounts.is_banned, accounts.ban_reason |
Account lifetime | Set by POST /v1/admin/accounts/:id/ban; logged in admin_audit_logs |
createdAt / updatedAt |
timestamp | Server-generated | Contract | accounts.created_at, accounts.updated_at |
Account lifetime | — |
Phones (account_phones)¶
Multi-phone, encrypted at rest. Schema: account-phones.schema.ts.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
| Full E.164 number | string (encrypted) | User input at "add phone" | Consent (phone is optional; MFA + verification) | account_phones.phone_e164_encrypted (AES-256-GCM via FieldCryptoService) |
Until user removes via portal | Decryption requires FIELD_ENCRYPTION_KEY; only the owner (JWT) or super-admin sees decrypted value |
| Phone hash | SHA-256 hex | Derived from E.164 + pepper | Legitimate interest (cap-of-3 uniqueness check across accounts, linked-account discovery) | account_phones.phone_e164_hash |
Until phone removed | Peppered with PHONE_HASH_PEPPER env var to defeat rainbow-table on the small E.164 keyspace |
| Last 2 digits | string | Derived from E.164 | Legitimate interest (display hint in portal + admin) | account_phones.phone_last_digits |
Until phone removed | Plaintext — low-sensitivity (needle-in-haystack against keyspace) |
isPrimary / verifiedAt / mfaEnabled |
flags | User actions in portal | Consent | account_phones.* |
Until phone removed | Partial-unique-index enforces one-primary-per-account; CHECK constraint ensures mfa_enabled requires verified_at not null |
Account-portal security settings¶
Per-account opt-in security features. Schema: account-security.schema.ts and security fields on accounts.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
totpSecret (encrypted) |
string | TOTP setup flow generates | Consent (MFA opt-in) | accounts.totp_secret (encrypted via FieldCryptoService) |
Until user disables TOTP | Plaintext secret never persisted |
totpEnabled |
boolean | User toggle | Consent | accounts.totp_enabled |
Account lifetime | — |
recoveryCodes |
hashed string[] | Generated at TOTP setup | Consent | accounts.recovery_codes (JSONB; SHA-256 hashes) |
Until regenerated or TOTP disabled | Plaintext codes shown once at setup; only hashes stored |
mfaEnforceOnOauth |
boolean | User toggle | Consent | accounts.mfa_enforce_on_oauth |
Account lifetime | — |
loginNotifications |
boolean | User toggle | Consent | accounts.login_notifications |
Account lifetime | Default true; user can opt out |
| Geo rule | mode + countries[] | User input via portal | Consent | account_geo_rules.* |
Until user removes | Mode allow or restrict; ISO-3166-1 alpha-2 codes |
| Allowed IPs | name + type + IP/range | User input via portal | Consent | account_allowed_ips.* |
Until user removes | Types: current / static / range |
| App passwords | name + hash + lastUsedAt | User-generated in portal | Consent (alternative auth for apps without TOTP support) | account_app_passwords.* |
Until user revokes | bcrypt hash; plaintext shown once at creation |
KYC / verified-identity data¶
Schema: kyc-verifications.schema.ts. Written by the KYC-running product (today: ZevPay) via POST /v1/internal/kyc-report; ZevID stores the canonical outcome so every product can read it without re-running KYC.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
status |
enum | KYC-running product report | Legal obligation (NDPC GAID + CBN AML/CFT) | kyc_verifications.status |
7 years post-account-deletion (CBN AML retention; confirmed by deployment owner). Currently no automatic purge mechanism — see retention.md for the implementation gap |
One of none / pending / verified / rejected |
verifiedFirstName / verifiedLastName |
string | KYC verification result | Legal obligation | kyc_verifications.verified_*_name |
Same as status | Authoritative legal name post-KYC |
dateOfBirth |
date | KYC verification result | Legal obligation | kyc_verifications.date_of_birth |
Same as status | — |
bvnHash |
SHA-256 hex | Derived from BVN at verification time | Legal obligation | kyc_verifications.bvn_hash (varchar(128)) |
Same as status | Plaintext BVN is NEVER stored — only the hash. The hash is one-way; we can prove "this user has BVN X" but cannot reverse to BVN |
ninHash |
SHA-256 hex | Derived from NIN at verification time | Legal obligation | kyc_verifications.nin_hash |
Same as status | Same hash-only treatment as BVN |
faceMatch |
boolean | KYC verification result | Legal obligation | kyc_verifications.face_match |
Same as status | True iff KYC provider returned a passing face-match |
verifiedBy |
string | clientId of reporting product | Legal obligation | kyc_verifications.verified_by |
Same as status | Typically zevpay |
verifiedAt |
timestamp | Server-set at write time | Legal obligation | kyc_verifications.verified_at |
Same as status | — |
Behavioural / activity data¶
Authentication events + active sessions. Used for security forensics, "Connected Apps & Activity" portal view, and admin investigation.
login_events¶
Schema: login-events.schema.ts. The account-security event log.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
eventType |
string | Server-set at event time | Legitimate interest (security) | login_events.event_type |
90 days (cleanup.service.ts:67-71 — daily 3am cron). Confirmed by deployment owner |
Values include: signup_initiated, otp_sent, login_success, login_failed, login_blocked_geo, login_blocked_ip, password_changed, product_authorized, phone_added, phone_verified, phone_removed, phone_set_primary, phone_mfa_enabled, phone_mfa_disabled, phone_reverification_requested, phone_reverification_confirmed |
ipAddress |
string (45) | Captured from request | Legitimate interest (security) | login_events.ip_address |
90 days | Includes IPv4 + IPv6 |
userAgent |
string (512) | Captured from request | Legitimate interest (security) | login_events.user_agent |
90 days | — |
productHint |
string (30) | Set at auth flow | Legitimate interest | login_events.product_hint |
90 days | Which OAuth client triggered the event (portal, zevpay, zevcloud, etc.) |
details |
JSONB | Event-specific context | Legitimate interest | login_events.details |
90 days | Free-form per event type (e.g. phone events carry phoneId, phoneHint) |
sessions¶
Schema: sessions.schema.ts. Active refresh-token sessions.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
tokenHash |
SHA-256 | Derived from refresh token | Contract | sessions.token_hash (unique) |
Until session expiry (then cleanup cron sweeps) | Plaintext refresh token NEVER persisted |
previousTokenHash |
SHA-256 | Previous-rotation token hash | Contract | sessions.previous_token_hash |
Same | Refresh-token rotation with reuse-detection is enabled in production |
clientId |
string | OAuth client the session was issued for | Contract | sessions.client_id |
Same | Becomes the aud claim on identity tokens |
userAgent / ipAddress / location / deviceName / platform / browser / os |
strings | Parsed from request UA + IP geolocation | Contract + Legitimate interest | sessions.* |
Same | UA parsed via ua-parser-js; IP geolocated via ip-api.com (cached) |
appVersion |
string | Caller-supplied | Contract | sessions.app_version |
Same | Used by mobile clients |
lastActiveAt / expiresAt / createdAt |
timestamps | Server-set | Contract | sessions.* |
Same | Cleanup cron deletes rows where expires_at < now() daily |
Device / network data¶
Captured inline with sessions + login events above. ZevID does not run a separate device-fingerprinting product — the userAgent + ipAddress + parsed platform/browser/os are the entire signal set. No third-party device-ID service is involved.
| Field | Where it lives |
|---|---|
| IP address (login + session events) | login_events.ip_address, sessions.ip_address |
| User-Agent string | login_events.user_agent, sessions.user_agent |
Parsed device descriptors (platform, browser, os, deviceName) |
sessions.* |
| IP-derived location (city, country) | sessions.location |
Retention same as host row (login_events: 90 days; sessions: until expiry).
Communications¶
System-generated transient messages (OTPs) and invitations.
OTPs¶
Schema: otps.schema.ts. One-time codes for email + SMS verification.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
code |
string (10) | Server-generated 6-digit | Contract (auth) | otps.code |
Deleted on expiry (10-min TTL) by daily cleanup cron | Per-row attempts counter caps at maxAttempts (default 5) |
channel |
enum | Set at generation | Contract | otps.channel |
Same | email (default) or sms |
purpose |
string (100) | Set at generation | Contract | otps.purpose |
Same | Namespaced: login, phone_verification:<phoneId>, mfa_sms:<phoneId>, etc. |
attempts / maxAttempts / expiresAt / usedAt |
counters/timestamps | Server-managed | Contract | otps.* |
Same | Account-level lockout layered on via LockoutService |
invites¶
Schema: invites.schema.ts. Product-issued invitations (e.g. ZevCommerce inviting a teammate).
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
email |
string | Inviter input | Consent (the inviting product collected and forwarded) | invites.email |
Retained for audit; rows expire via the status=expired state but row stays on file |
Email is the invitee, not the inviter; inviter identity lives in context JSONB |
clientId |
string | Inviting product's clientId | Contract | invites.client_id |
Same | — |
tokenHash |
SHA-256 | Derived from signed invite token | Contract | invites.token_hash (unique) |
Same | Plaintext token sent to user via email; only hash persisted |
accountId (nullable) |
UUID | Linked when the invitee accepts | Contract | invites.account_id |
Same | Null until acceptance |
context |
JSONB | Inviting product supplies role/team info | Consent | invites.context |
Same | Opaque to ZevID; relayed back at accept time |
status |
enum | Lifecycle managed by ZevID | Contract | invites.status |
Same | pending / accepted / expired / revoked |
Cross-product enrollment data¶
Schema: product-enrollments.schema.ts. One row per (user × product); kept in sync by each product calling PUT /v1/internal/users/:accountId/enrollments/:productClientId.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
product |
string | Product's clientId | Contract | product_enrollments.product |
Account lifetime; isActive flag flips false on de-enrollment | One of the registered oauth_clients.client_id values |
productUserId (nullable) |
string | Product-defined identifier | Contract | product_enrollments.product_user_id |
Same | Product's internal id for the user (e.g. ZevPay's wallet id) |
tier (nullable) |
string | Product-defined tier | Contract | product_enrollments.tier |
Same | Opaque to ZevID; e.g. personal_kyc1, business_kyc2, merchant |
metadata |
JSONB | Product-defined sub-entities | Contract | product_enrollments.metadata |
Same | Opaque to ZevID; conventional shape: arrays of owned entities with { id, name, ... } |
enrolledAt / updatedAt / isActive |
timestamps + flag | Server-managed at write time | Contract | product_enrollments.* |
Same | — |
ZPIP consent + token audit¶
Cross-product permissions and the audit trail of every cross-product token issued. Schema: zpip-tokens.schema.ts.
zpip_consent_requests¶
Pending consent screens. 10-min TTL; terminal-state rows retained for audit. See retention.md.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
callerClientId |
string | Service-key calling token-exchange | Contract | zpip_consent_requests.caller_client_id |
Retained for audit | The product requesting consent |
scopes |
text[] | Caller-supplied | Contract | zpip_consent_requests.scopes |
Indefinite | All scopes in one batch share owner-product |
accountId |
UUID | Caller-supplied; FK to accounts | Contract | zpip_consent_requests.account_id |
Indefinite | — |
grantedScopes / grantIds |
text[] / uuid[] | Set on approval (subset of scopes) |
Consent | zpip_consent_requests.granted_scopes, .grant_ids |
Indefinite | Empty on Deny |
returnTo |
URL | Caller-supplied; validated against oauth_clients.redirect_uris |
Contract | zpip_consent_requests.return_to |
Indefinite | Subject to redirect-URI allowlist |
status / approvedAt / deniedAt / expiresAt / createdAt |
enum + timestamps | Lifecycle managed | Contract | zpip_consent_requests.* |
Indefinite | pending / approved / denied / expired |
zpip_scope_grants¶
Active user-granted scopes; the canonical "user X has consented for product Y to do Z."
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
accountId / callerClientId / scope |
UUID + strings | From consent approval | Consent (NDPA §25(1)(a)) | zpip_scope_grants.* (unique on the triple) |
Until user revokes; revoked rows kept for audit (revoked_at set, row retained) |
Per-scope row; batched consent inserts N rows |
grantedAt / revokedAt / expiresAt |
timestamps | Lifecycle | Consent | zpip_scope_grants.* |
Indefinite | revokedAt set by user-driven revoke from Connected Apps |
metadata |
JSONB | Captured at consent time | Legitimate interest (audit) | zpip_scope_grants.metadata |
Indefinite | Currently stores userAgent of consent action |
zpip_token_log¶
Every cross-product token issued. Replay-protection + audit.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
jti |
UUID | Server-generated per token | Legitimate interest (replay protection + audit) | zpip_token_log.jti (unique) |
Retained for audit (CBN 7-year retention applies to financial-record-adjacent rows) | — |
callerClientId / audience / actForAccountId / scope / consentId / issuedAt / expiresAt |
strings + UUID + timestamps | Server-set at issuance | Legitimate interest | zpip_token_log.* |
Same | actForAccountId null for service-only tokens |
Financial data (Zev Credit ledger)¶
ZevID owns the centralised "Zev Credit" ledger — non-withdrawable spending power. Subject to financial-record retention.
Schema: credits.schema.ts. Four tables: credit_grants, credit_transactions, credit_transaction_lines, credit_idempotency.
| Field group | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|
Grant rows: accountId, subAccount, currency, amountGranted, remainingAmount, source, scopeType/scopeValue, expiresAt, status, issuedByType/Id, idempotencyKey, metadata |
Server + product calls | Legal obligation (financial records, CBN 7-year retention) | credit_grants.* |
Retained for CBN 7-year financial-record obligation. ON DELETE RESTRICT on accounts — hard delete refused while grants exist |
Source: referral / promo / prepayment / refund / goodwill / signup_bonus |
Transaction headers: id, type, actorClientId, currency, amount, reason, idempotencyKey, createdAt |
Cascade engine | Legal obligation | credit_transactions.* |
Same | Type: debit / reversal |
| Transaction lines: header link + grant link + amount | Cascade engine | Legal obligation | credit_transaction_lines.* |
Same | One line per drained grant; double-entry |
Idempotency: (scope, key) |
Server | Legal obligation | credit_idempotency.* |
Same | Stable replay responses |
Referrals¶
Schema: referrals.schema.ts. Two tables: referral_codes, referrals.
| Field | Type | Source | Lawful basis | Storage | Retention | Notes |
|---|---|---|---|---|---|---|
referralCodes.code |
string (30, unique) | User-created or admin-created | Consent (user shared their code) | referral_codes.code |
Account lifetime; isActive flag for soft disable |
Codes can be scoped to one or more products |
referrals.referrerId / referredAccountId |
UUIDs | Captured at referred user's signup | Consent + Legitimate interest (referral-reward tracking) | referrals.* |
Account lifetime | — |
sourceProduct / method / status |
strings | Lifecycle | Contract | referrals.* |
Same | Method: referral_link or promo_code |
Special categories (NDPA Sensitive Personal Data)¶
Not applicable — ZevID itself processes no special-category data (health, biometric, racial/ethnic, political, religious, sexual-orientation, genetic).
Edge note: the face-match boolean in kyc_verifications.face_match is a derived signal, not biometric data. ZevID stores only the boolean outcome; the underlying face image lives at the KYC provider (currently ZevPay's KYC vendor — see ZevPay's section). If ZevID ever starts storing biometric templates directly, this section must be updated and the DPO consulted before merge.
Future changes¶
None currently planned. When fields are added, the matching schema migration PR must include the row update here and an entry in change-log.md.