Skip to main content
Causeloop is built with a defence-in-depth approach: tenant isolation at the database layer, envelope encryption for secrets, scoped JWT auth, and fail-closed webhook verification. This page documents each layer.

Tenant isolation — Row-Level Security

Every piece of tenant data in Causeloop carries a workspace_id column. PostgreSQL Row-Level Security (RLS) enforces that a database session can only read and write its own workspace’s rows — even if the application layer had a bug and issued a query without a workspace filter.

How it works

At the start of every request, the application sets a session-local GUC:
The database function app_current_tenant() reads this GUC. Every tenant table has four policies:
FORCE ROW LEVEL SECURITY means the table owner is also subject to the policies in the application role’s session.

The two-role model

RLS is only effective if the application connects as a role with NOBYPASSRLS. This is the most critical operational security requirement:
Connecting as a Postgres superuser, the Neon neondb_owner role, or any role with BYPASSRLS silently disables all RLS policies. If you do this in production, tenant data is not isolated.Always configure DATABASE_URL to use a dedicated causeloop_app role with NOSUPERUSER and NOBYPASSRLS. See Database setup — RLS two-role model.

RLS coverage

Migration 0001_force_rls.sql enables FORCE ROW LEVEL SECURITY on all tenant tables and the audit log. As of the current codebase, RLS policies cover approximately 18 of ~45 tenant tables; complete coverage across all tables is an active work item. See SOC 2 readiness for the current gap status.

Envelope encryption — KEK / DEK

Secrets stored in the database (connector configurations, webhook signing secrets, MFA factor seeds) are encrypted with AES-256-GCM using a two-layer envelope scheme.

Architecture

  • KEK (Key Encryption Key): the CAUSELOOP_MASTER_KEY environment variable — a base64-encoded 32-byte AES-256 key that lives outside the database. It never touches the database.
  • DEK (Data Encryption Key): a per-workspace 256-bit AES key, generated on first use, stored in workspace_keys in wrapped (encrypted) form.
  • Ciphertext layout: version(1 byte) | nonce(12 bytes) | ciphertext+tag

Implementation (app/crypto/envelope.py)

The workspace_id is used as Additional Authenticated Data (AAD), preventing ciphertext from one workspace being replayed into another.

DEK lifecycle

DEKs are generated lazily on first encrypt, stored in workspace_keys, and cached in-process with an lru_cache keyed on the KEK fingerprint. The cache is automatically invalidated when the master key changes. Key rotation: rotate the KEK by generating a new CAUSELOOP_MASTER_KEY, re-wrapping all DEKs, and updating workspace_keys. This is a planned operator runbook; per-workspace rotation is supported in the schema via the key_rotation_jobs table.
If you lose CAUSELOOP_MASTER_KEY, the wrapped DEKs in workspace_keys cannot be decrypted, and all encrypted columns become permanently unreadable. Store this key in a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, 1Password Secrets Automation) with at minimum one encrypted backup.

What is encrypted

Note: mfa_factors.secret_encrypted and connectors.config_encrypted columns exist in the schema. App-layer KMS encryption for these columns is partially implemented; full wiring is an active work item. See SOC 2 readiness.

Authentication and authorization

Token types

RBAC

Causeloop uses a typed permission catalogue. Roles (in descending privilege): Permissions are checked via require_scope("scope:action") decorators on route handlers. Example scopes: governance:write, audit:read, connectors:write.

Rate limiting

The application enforces a default token-bucket rate limit of 1,000 requests per minute across all routes, implemented in app/middleware/rate_limit.py. Limits apply per client IP.

Idempotency

POST endpoints that create resources accept an Idempotency-Key header. Duplicate requests with the same key within the deduplication window return the cached response without re-executing the operation.

Inbound webhook HMAC verification

Inbound webhooks from third-party services are verified using HMAC-SHA256. The verification is fail-closed: if the x-causeloop-signature header is absent or the signature does not match, the request is rejected with 401 Unauthorized.
Send the hex digest in the x-causeloop-signature header. The receiver compares it using hmac.compare_digest (constant-time comparison, prevents timing attacks). Fail-closed means: if the signature header is missing, or the HMAC secret has not been set for the inbound endpoint, the request is rejected. The application never falls through to process an unverified payload.

Transport security

  • TLS 1.3 via Caddy with automatic Let’s Encrypt certificates
  • Strict-Transport-Security: max-age=31536000 response header
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Server header suppressed by Caddy
  • CORS restricted to an explicit allowlist (CORS_ORIGINS env var)

Audit log

Every significant action in the system is recorded in the audit_log table:
The audit log is append-only, enforced by migration 0002_audit_trace_append_only.sql (an UPDATE/DELETE trigger that raises an exception). It is scoped by RLS, so each workspace can only read its own audit trail. Audit entries are covered by workspace retention policy via workspace_settings.audit_log_retention_days.

Secrets management

JWT_SECRET defaults to dev-secret-change-me. This value must be changed before any production deployment. Anyone with this value can forge valid JWTs for any user in the system.
In production, treat these as high-sensitivity secrets: Store these in a secrets manager, not in .env files checked into source control. For Railway, use the dashboard secrets panel. For Render, mark them as Secret environment variables. For VPS deploys, use Docker secrets or a vault solution.