Security model
CCD-Suite is a multi-tenant platform that handles customer-of- customer data (your CRM contacts, your emails, your pipeline). The security model is designed accordingly: every query is tenant- scoped at the database level, secrets are encrypted at rest, and the trust boundary is the smallest unit we could make it.
Tenant isolation
The single most important property: a query in tenant A's context never returns data from tenant B. Enforced at two layers, both defense-in-depth.
Layer 1: Postgres Row-Level Security (RLS)
Every multi-tenant table has an RLS policy keyed on tenant_id.
The policy reads the current session's JWT (auth.jwt() ->> 'tenant_id')
and joins it against the row's tenant_id. The policy is enforced
on every query — there's no way to read past it without bypassing
the RLS engine itself, which requires the postgres superuser
(which only the data-plane init scripts have).
The web app, the workers, and the agent runtime all use authenticated
Postgres roles (authenticated, service_role) that have RLS
applied. The service_role can BYPASS RLS — used for cross-tenant
admin operations — but every code path that uses it goes through
an admin-gate check that requires user_type = 'admin' on the
calling user.
Layer 2: Application checks
Even when code paths use service_role:
- API handlers explicitly check the calling user's permissions before dispatching.
- Long-running operations (cron, worker jobs) pass
p_tenant_idas an explicit parameter to every RPC and verify match. - The agent runtime's TurnRecorder tags every event with both the declared tenant_id AND the JWT tenant_id; mismatch fails the run.
This is "belt and suspenders" — Layer 1 should be sufficient, but we layer 2 on top because the cost of being wrong is high.
JWT contract
Authentication uses JWTs issued by Supabase GoTrue, signed with
JWT_SECRET. Every JWT includes:
{
"sub": "<user_id>",
"tenant_id": "<tenant_id>",
"role": "authenticated",
"aud": "authenticated",
"exp": <unix_ts>,
"iat": <unix_ts>
}
tenant_id is the foundation everything else builds on. It's set
at sign-in by GoTrue from the user's profile row; it can't be
manipulated by the client.
JWT expiry: 1 hour by default. Refresh tokens are rotated on every use (one-time-use). A leaked access token is bad for an hour; a leaked refresh token is detected on next use because the prior rotation invalidates it.
Service-to-service
The web app, api-gateway, ai-services, and worker call each other
with HMAC-signed requests: a shared secret plus a request-time
timestamp (X-Service-Signature / X-Service-Timestamp), verified
by the receiving service before processing. ai-services uses
SERVICE_SECRET and, on cloud, refuses to start without it; the
worker signs its calls to the web app with CCD_SERVICE_HMAC_SECRET.
This stops an attacker who can reach a service's endpoint, but holds no secret, from impersonating calls from another service.
Secrets at rest
The application uses AES-256-GCM envelope encryption with per-tenant data-encryption keys (DEKs) derived from a master key held in environment. The master key never touches the database; each tenant's DEK is generated on first use and itself encrypted with the master key before storage. The same envelope library is shared across every service that touches encrypted columns.
| Secret class | Mechanism | Notes |
|---|---|---|
JWT_SECRET | environment variable | HMAC-SHA256 signing key for JWTs. Never logged. |
POSTGRES_PASSWORD | environment variable | Cluster superuser. Cloud runs on Supabase-managed Postgres, so storage encryption at rest is Supabase's; on self-host the Postgres data dir is unencrypted at rest unless the filesystem is (operator's call). |
ENCRYPTION_MASTER_KEY | environment variable (32 bytes hex) | Wraps every tenant DEK. No automated rotation schedule; see the rotation note below. On cloud production, storing integration credentials fails closed if it is missing. |
| OAuth tokens (access + refresh) | directly connected tools: per-tenant AES-256-GCM (envelope encryption); connector-runtime tools: held by Nango, encrypted in its own database | For directly connected tools both tokens are stored, because both are needed to sync again, and deleted on disconnect. For tools on the connector runtime CCD never stores or reads a token — only the synced records. See Connections. |
| Integration credentials (Stripe, HubSpot, Google, QuickBooks, Salesforce, custom API keys) | per-tenant AES-256-GCM (envelope encryption) | Wrapped into a { __enc: 'enc:v1:<base64>' } envelope in the JSONB column. See Phase 4.A.1. |
| API keys | scrypt hash | Only the hash is stored. Full key shown ONCE at creation and never re-displayed. |
| PII fields (employee SSN, contact email/phone, etc.) | per-tenant AES-256-GCM (envelope encryption) | Plus a deterministic blind index (HMAC-SHA256 keyed by tenant DEK) for equality lookups on encrypted fields. Contact email/phone are encrypted when written via the CRM API, connectors, or CSV import; contacts created by agents are not yet encrypted. |
DEK rotation posture. Per-tenant DEKs are versioned: each tenant has a current active key plus zero or more rotated/revoked historical keys, and multiple versions can coexist. New writes always use the active key; reads look up the version embedded in the envelope, so rows encrypted under a rotated DEK keep decrypting after rotation. Per-tenant DEKs are generated on first use; automated rotation is not yet scheduled — a rotation cron is tracked for a later phase, and there is no fixed rotation cadence for
ENCRYPTION_MASTER_KEYeither. Operators with stricter requirements can rotate a tenant's DEK manually via the admin DEK-rotation API; existing ciphertext remains decryptable via the envelope's version pointer.
Some historical docs referenced pgsodium as the mechanism for OAuth-token storage. That was aspirational; the actual implementation has always used the application-layer AES-256-GCM library described above. We don't ship pgsodium as a Postgres extension — encryption happens in Node before insert.
Audit log
Every mutating action is logged with (actor_user_id, action, target_id, timestamp, request_id, before_state, after_state).
Retention is indefinite by design. The audit log is the immutable record of every mutating action; pruning it would break the SHA-256 hash chain that detects tampering. UI access is gated by plan tier:
| Tier | UI access |
|---|---|
| Cloud · Starter | Per-entity history pane (last 30 days surfaced) |
| Cloud · Scale | Per-entity + tenant-wide search + export (last 1 year surfaced) |
| Cloud · Enterprise | All of the above + immutable signed export (all-time surfaced) |
| Self-host (community + enterprise) | Per-entity + tenant-wide search (all-time surfaced) |
The "surfaced" window is a UI convention — older entries stay in the table, queryable via the export API and the REST endpoint.
Module-level data (CRM activities, AI conversations, analytics events,
finance records) HAS active retention enforcement via the
/api/cron/enforce-retention daily cron — see the Configuration
docs for per-module knobs.
The audit log table revokes UPDATE/DELETE from the authenticated
role at the database level — application code paths that hit the
table via PostgREST cannot mutate prior entries even if a bug
tries. The service_role retains the grant (Supabase requires it
for the trigger-writing path), but every service-role-using code
path is reviewed in CI for audit-table writes outside the standard
logAudit() helper. The Postgres GRANT is applied at deploy time
via the immutable-audit-logs migration.
Encryption in transit
- Browser ↔ server: TLS. The web app sends
Strict-Transport-Security: max-age=31536000; includeSubDomains. - Server ↔ Postgres: TLS within the data plane (cloud), or loopback in single-host self-host. Self-host operators exposing Postgres to a non-local network MUST configure TLS; the install docs flag this.
- Server ↔ external (Stripe, Ayrshare, OAuth providers): HTTPS, using standard certificate validation.
DSAR + data deletion (cloud Enterprise)
Two paths cover GDPR Article 15 (right of access) + Article 17 (right to erasure):
Self-serve (every authenticated user, all editions)
Settings → Privacy surfaces two actions for the signed-in user:
| Action | Endpoint | Behaviour |
|---|---|---|
| Download my data | POST /api/me/data-export | Generates a JSON archive of every row attributable to the user across the DSAR data map. Synchronous; rate-limited to 3 exports / hour / user (turns into a data-exfiltration amplifier if unbounded). |
| Request deletion | POST /api/me/deletion-request | Creates a deletion request with a 72-hour grace period. An administrator confirms execution after the grace expires. User can DELETE the same endpoint to cancel before grace ends. Rate-limited to 1 deletion request / day / user. |
Self-serve requests are flagged metadata.actor = 'self' in
dsar_requests so the admin queue distinguishes them from paper
requests fulfilled by support.
Admin-fulfilled (paper requests, customer escalations)
| Action | Endpoint | Required role |
|---|---|---|
| Export on behalf of a user | POST /api/admin/compliance/dsar/export | Tenant admin |
| Create deletion request on a user | POST /api/admin/compliance/dsar/delete | Tenant admin |
| Confirm after grace | POST /api/admin/compliance/dsar/delete/[id]/confirm | Tenant admin |
| Cancel before grace ends | POST /api/admin/compliance/dsar/delete/[id]/cancel | Tenant admin |
The deletion executor is idempotent: per-table errors are reported
in the result manifest and the request is marked
completed_with_errors rather than completed so the admin can
re-confirm to retry just the failures.
Edition + license gating splits the surfaces:
/api/me/*(user self-serve) — gated on thedsar_self_servelicense feature. Enterprise + cloud unlock it; community returns 402. Community operators handle GDPR Article 15/17 requests off-platform (Postgres-level export + scrubbing)./api/admin/compliance/dsar/*(system-admin queue) — gated ondsar_admin_queue. Same enterprise + cloud unlock; community has no super-admin DSAR console.- The two flags are issued together by sales for any enterprise license, but the split lets compliance teams reason about them independently.
There is no edition gate at the route's edge — both feature codes
are checked via requireFeatureOrRespond(), which honors the
license tier at the entitlement layer.
Vulnerability disclosure
Email hello@ccdsuite.com (the address on the public Security page). We coordinate disclosure with reporters.
What we DON'T claim
For honesty:
- SOC 2 / ISO 27001: not certified.
- HIPAA / FedRAMP: not in scope for the current product.
- Penetration test: no external penetration test report to share yet.
- Open security audit of the codebase: not yet. The MIT- licensed self-host code is freely auditable; we welcome reviews.
Reporting practices
Cloud edition automatically reports errors to Sentry. See Subprocessors for every third party that processes data on cloud.
Self-host has no telemetry on by default. Operators can opt into Grafana via the observability profile — that data stays local.
Read next
- Self-host production hardening — operational steps to lock down a self-host install.
- FAQ — common security-flavored questions.