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 talk to each
other over the docker network (self-host) or internal VPC
endpoints (cloud). Each request is HMAC-signed with
CCD_SERVICE_HMAC_SECRET + a request-time timestamp; the receiving
service verifies before processing.
This stops an attacker who got into one service from impersonating calls to another. Combined with network segmentation (services only listen on internal interfaces, never public), the lateral- movement surface is small.
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. Postgres data dir is unencrypted at rest unless the filesystem is (cloud: AWS RDS storage encryption is on; self-host: operator's call). |
ENCRYPTION_MASTER_KEY | environment variable (32 bytes hex) | Wraps every tenant DEK. Rotated annually on cloud; self-host operator controls cadence. |
| OAuth refresh tokens | per-tenant AES-256-GCM (envelope encryption) | Access tokens are never persisted; only refresh tokens, encrypted at the application layer before insert. |
| Integration credentials (Stripe, HubSpot, Google, QuickBooks, 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. |
| MCP server credentials | per-tenant AES-256-GCM (envelope encryption) | Same envelope shape as OAuth refresh tokens. |
| API keys | scrypt hash | Only the hash is stored. Full key shown ONCE at creation and never re-displayed. |
| PII fields (employee SSN, 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. |
Cloud edition rotates ENCRYPTION_MASTER_KEY annually. Tenant DEKs
are re-wrapped under the new master key; the encrypted-field
ciphertext doesn't need re-encryption because the DEK underneath is
unchanged.
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. Master key rotation (
ENCRYPTION_MASTER_KEY, annual on cloud) provides the primary cryptographic hygiene because every DEK is wrapped by the master key. Operators with stricter requirements can rotate 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 and MCP-credential 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 1.2+ required. HSTS preloaded.
- 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): TLS, with certificate pinning for high-stakes endpoints (Stripe payment APIs).
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 security@ccdsuite.com. We acknowledge within 48 hours, fix critical issues within 7 days, and coordinate disclosure with reporters per the standard 90-day window.
A simple bug-bounty: triaged critical findings get a credit on our security page; high-impact findings get a cash bounty ($500-$5000 range depending on severity + reporter cooperation).
What we DON'T claim
For honesty:
- SOC 2 / ISO 27001: not yet (formal audit in progress for cloud edition; targeted 2026 Q4).
- HIPAA / FedRAMP: not in scope for the current product.
- Penetration test: cloud edition has had quarterly external pentests; reports available under NDA.
- 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. Sentry data is processed in the US (Sentry's policy). Tenants on Enterprise can request EU data residency via support.
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.