> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Security Model

> How Causeloop isolates tenants, encrypts secrets, authenticates users, and handles inbound webhooks securely.

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:

```python theme={null}
# 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:

```sql theme={null}
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:

<Warning>
  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](/deploy-security/database#rls-two-role-model).
</Warning>

### 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](/deploy-security/soc2) 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`)

```python theme={null}
# 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.

<Warning>
  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.
</Warning>

### What is encrypted

| Table              | Column             | Encrypted                                                            |
| ------------------ | ------------------ | -------------------------------------------------------------------- |
| `connectors`       | `config_encrypted` | Connector OAuth tokens, API keys                                     |
| `webhooks`         | `secret_encrypted` | Outbound webhook signing secret                                      |
| `inbound_webhooks` | `secret_encrypted` | Inbound webhook HMAC secret                                          |
| `mfa_factors`      | `secret_encrypted` | TOTP 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](/deploy-security/soc2).

## Authentication and authorization

### Token types

| Type                                                          | Mechanism                                       | Use case                                                                                   |
| ------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------ |
| **WorkOS access token** (or other configured `AUTH_PROVIDER`) | RS256, verified via JWKS URL                    | Frontend user sessions — exchanged at `POST /v1/auth/exchange` for a Causeloop HS256 token |
| **Causeloop JWT**                                             | HS256, signed with `JWT_SECRET`                 | Service-to-service and frontend API calls after session exchange                           |
| **Personal Access Token (PAT)**                               | SHA-256 hash stored in `personal_access_tokens` | Long-lived developer/CI tokens                                                             |
| **Service Account token**                                     | Scoped HS256 JWT for machine identities         | Automation and integrations                                                                |

### RBAC

Causeloop uses a typed permission catalogue. Roles (in descending privilege):

| Role      | Access                                                                   |
| --------- | ------------------------------------------------------------------------ |
| `admin`   | Full workspace management including provisioning, GDPR, billing settings |
| `owner`   | Same as admin for workspace they own                                     |
| `analyst` | Read + write on issues, patterns, predictions, recommendations           |
| `viewer`  | Read-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`.

```python theme={null}
# 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:

```sql theme={null}
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

<Warning>
  `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.
</Warning>

In production, treat these as high-sensitivity secrets:

| Secret                                 | Risk if exposed                                                     |
| -------------------------------------- | ------------------------------------------------------------------- |
| `CAUSELOOP_MASTER_KEY`                 | All encrypted connector configs and webhook secrets become readable |
| `JWT_SECRET`                           | JWT forgery — arbitrary impersonation                               |
| `DATABASE_URL`                         | Direct database access — all tenant data                            |
| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | API 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.

## Related pages

* [Database setup — RLS two-role model](/deploy-security/database#rls-two-role-model)
* [SOC 2 readiness](/deploy-security/soc2) — current control status
* [GDPR & data governance](/deploy-security/gdpr-governance)
