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_id as 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 classMechanismNotes
JWT_SECRETenvironment variableHMAC-SHA256 signing key for JWTs. Never logged.
POSTGRES_PASSWORDenvironment variableCluster 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_KEYenvironment variable (32 bytes hex)Wraps every tenant DEK. Rotated annually on cloud; self-host operator controls cadence.
OAuth refresh tokensper-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 credentialsper-tenant AES-256-GCM (envelope encryption)Same envelope shape as OAuth refresh tokens.
API keysscrypt hashOnly 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:

TierUI access
Cloud · StarterPer-entity history pane (last 30 days surfaced)
Cloud · ScalePer-entity + tenant-wide search + export (last 1 year surfaced)
Cloud · EnterpriseAll 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:

ActionEndpointBehaviour
Download my dataPOST /api/me/data-exportGenerates 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 deletionPOST /api/me/deletion-requestCreates 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)

ActionEndpointRequired role
Export on behalf of a userPOST /api/admin/compliance/dsar/exportTenant admin
Create deletion request on a userPOST /api/admin/compliance/dsar/deleteTenant admin
Confirm after gracePOST /api/admin/compliance/dsar/delete/[id]/confirmTenant admin
Cancel before grace endsPOST /api/admin/compliance/dsar/delete/[id]/cancelTenant 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 the dsar_self_serve license 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 on dsar_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