Security, Flags & Data Retention
Business view
The CRM handles two sensitive things: platform access tokens (which can spend a clinic's ad budget and read their messages) and personal data in webhook payloads (names, phone numbers, message text). The module was built with that in mind.
- Tokens are encrypted at rest and are never returned to any screen or API response.
- Personal data in the raw webhook log is automatically redacted after 90 days and
deleted after a year — a privacy-retention safeguard, not just a cleanup job.
- Every capability is double-gated: a clinic must both have the feature switched on and
the individual staff member must hold the right permission.
- Tenant isolation is never guessed. When a webhook arrives, Dentolize figures out which
clinic it belongs to from its own stored mappings — never from data the sender controls.
Technical view
The token vault (AES-256-GCM)
packages/utilities/src/cryptoVault/index.ts implements authenticated encryption: aes-256-gcm, 12-byte random IV, 32-byte key (:17). The wire format is versioned — v<keyId>:<iv>:<authTag>:<ciphertext> (:7, validated by a FORMAT_REGEX, :20) — which makes key rotation possible: new writes use the current key (SOCIAL_TOKEN_ENC_KEY + SOCIAL_TOKEN_ENC_KEY_ID), while reads select the key by the id embedded in the ciphertext, so retired keys (SOCIAL_TOKEN_ENC_KEYS_OLD) can still decrypt. Migration helpers readSecret / rotateSecret (:97, :109) dual-read plaintext-or- encrypted columns and re-encrypt only when a value was written with a retired/plaintext key.
The server wrapper socialTokenVault.js exposes sealSocialToken (encrypt) and readSocialToken (:17), which dual-reads: encrypted values are decrypted; anything else is treated as a legacy signed JWT and verified with FACEBOOK_JWT_SECRET (:24).
Fixing the legacy WhatsApp token
The legacy OfficialWhatsApp.accessToken was stored as a signed JWT, not encryption — its Facebook token was readable by anyone with database access. The one-off script yarn encrypt-wa-tokens (packages/server/package.json, → encryptOfficialWhatsAppTokens.js) re-encrypts those rows: it skips already-encrypted rows (idempotent), decodes the JWT via readSocialToken, re-seals via sealSocialToken, and exits non-zero on any failure. Because readSocialToken dual-reads, the script is safe to run before or after deploy.
Deploy prerequisite:SOCIAL_TOKEN_ENC_KEY(base64, 32 bytes) must be set. Thewhatsapp-officialgateway fails fast at boot if it's missing — it round-trips an encryption probe on startup.
90-day PII redaction
packages/server/src/cronJobs/crm/webhookRetentionCron.js (0 4 * * *, cronJobs.js:193) enforces retention: REDACT_AFTER_DAYS = 90 (:5) — it rewrites WebhookEvent.payload to { redacted: true } for rows older than 90 days that aren't already redacted, and deletes rows after ~365 days. It exempts RECEIVED rows (:9), which are parked, unprocessed events that still need their payload to be replayed. This is the GDPR/PDPL path, since raw payloads carry lead names, phone numbers, and message text.
Two-layer gating: flags + permissions
- Feature flags decide whether the capability exists for a tenant.
assertFeatureFlagEnabled(prisma, key, companyId) (packages/server/src/utils/featureFlagUtils.js:147) throws if the flag isn't globally active, then evaluates the flag's positive/negative rule DSL against the company. Its docstring is explicit: "Shield permissions gate who may call; this gates whether the capability exists for the tenant." The eight FEATURE_CRM_* flags are seeded by yarn seed-feature-flags (seedFeatureFlags.js:40), all beta-gated (Rule('isBeta','EQUALS',true)).
| Flag | Gates |
|---|---|
FEATURE_CRM_INTEGRATIONS_{META,TIKTOK,SNAPCHAT,GOOGLE} | per-platform connect |
FEATURE_CRM_MARKETING_HUB | marketing posting config, ad credit/invoice, blasts |
FEATURE_CRM_INBOX_UNIFIED | inbox config |
FEATURE_CRM_AUTOMATION | automation rules |
FEATURE_CRM_REVIEWS | review config, requests, replies, flags |
- GraphQL Shield permissions decide who may call. New CRM permissions include
EDIT_CRM_INTEGRATIONS, VIEW_MARKETING, VIEW_MARKETING_ROI, VIEW_REVIEW, REPLY_REVIEW (reusing EDIT_LEAD_SETTINGS, SEND_WA, VIEW_WA). Query rules at permissions.js:2814; mutation rules at :4378 — all chained with isAuthenticated and hasLeadsAccess.
> One asymmetry to know: the web app gates every CRM surface behind the FEATURE_CRM_* > flags, but the mobile app does not — mobile shows CRM entries based only on > permissions + company.leadsEnabled. See Scope, Gaps & Honest Notes.
Webhook security properties
- Signature verification — HMAC-SHA256, constant-time (
crypto.timingSafeEqual); Meta's
x-hub-signature-256 is checked against FACEBOOK_APP_SECRET, Google's shared google_key against SocialIntegration.webhookKey. Failures return 401.
- Tenant routing from server-held mappings only — never from sender-controlled payload
fields (webhook-ingest.service.ts:46, lead-form.controller.ts:24). Unroutable events are stored DEAD, not attributed to a guessed company.
- Idempotent ingest —
WebhookEvent @@unique([platform, externalEventId])collapses
redeliveries.
- Tokens never leave the server — the
SAFE_SELECTused by integration resolvers omits
all token fields.