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:
# Pseudocode — actual implementation is in the repository abstraction layer
conn.execute("SELECT set_config('app.current_workspace', %s, true)", [workspace_id])
The database function app_current_tenant() reads this GUC. Every tenant table has four policies:
ALTER TABLE issues ENABLE ROW LEVEL SECURITY;
ALTER TABLE issues FORCE  ROW LEVEL SECURITY;

CREATE POLICY tenant_select ON issues FOR SELECT
    USING (workspace_id = app_current_tenant());

CREATE POLICY tenant_insert ON issues FOR INSERT
    WITH CHECK (workspace_id = app_current_tenant());

CREATE POLICY tenant_update ON issues FOR UPDATE
    USING (workspace_id = app_current_tenant())
    WITH CHECK (workspace_id = app_current_tenant());

CREATE POLICY tenant_delete ON issues FOR DELETE
    USING (workspace_id = app_current_tenant());
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

CAUSELOOP_MASTER_KEY (KEK)
  └── wraps → per-workspace Data Encryption Key (DEK)
                stored encrypted in workspace_keys table
                  └── encrypts → secret columns
                                 (config_encrypted, secret_encrypted, ...)
  • 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)

# AES-256-GCM envelope encryption
VERSION = 1

def encrypt(database_url: str, workspace_id: str, plaintext: bytes) -> bytes:
    dek = _dek(database_url, workspace_id)          # fetch+unwrap or generate DEK
    nonce = os.urandom(12)
    ct = AESGCM(dek).encrypt(nonce, plaintext, workspace_id.encode())  # AAD = workspace_id
    return struct.pack("B", VERSION) + nonce + ct

def decrypt(database_url: str, workspace_id: str, blob: bytes) -> bytes:
    dek = _dek(database_url, workspace_id)
    nonce, ct = blob[1:13], blob[13:]
    return AESGCM(dek).decrypt(nonce, ct, workspace_id.encode())
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

TableColumnEncrypted
connectorsconfig_encryptedConnector OAuth tokens, API keys
webhookssecret_encryptedOutbound webhook signing secret
inbound_webhookssecret_encryptedInbound webhook HMAC secret
mfa_factorssecret_encryptedTOTP seed (MFA stub — encryption column exists; full MFA is planned)
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

TypeMechanismUse case
Clerk session JWTRS256, verified via JWKS URLFrontend user sessions — exchanged at POST /v1/auth/exchange for a Causeloop HS256 token
Causeloop JWTHS256, signed with JWT_SECRETService-to-service and frontend API calls after session exchange
Personal Access Token (PAT)SHA-256 hash stored in personal_access_tokensLong-lived developer/CI tokens
Service Account tokenScoped HS256 JWT for machine identitiesAutomation and integrations

RBAC

Causeloop uses a typed permission catalogue. Roles (in descending privilege):
RoleAccess
adminFull workspace management including provisioning, GDPR, billing settings
ownerSame as admin for workspace they own
analystRead + write on issues, patterns, predictions, recommendations
viewerRead-only
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.
# Sender-side signature computation
import hashlib, hmac

def compute_signature(raw_body: bytes, secret: str) -> str:
    return hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
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:
audit_log (
    id, workspace_id, actor_id, actor_name,
    action, action_category,
    target_type, target_id,
    before JSONB, after JSONB,
    ip_address, trace_id,
    created_at TIMESTAMPTZ
)
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:
SecretRisk if exposed
CAUSELOOP_MASTER_KEYAll encrypted connector configs and webhook secrets become readable
JWT_SECRETJWT forgery — arbitrary impersonation
DATABASE_URLDirect database access — all tenant data
ANTHROPIC_API_KEY / OPENAI_API_KEYAPI cost abuse
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.