Multi-Tenancy Security

Causet isolates tenant data at the database schema level. Each fork of an application has its own PostgreSQL schema. All reads and writes to projection and entity state tables are scoped to the authenticated tenant’s schema for the duration of the transaction.


Tenant hierarchy

Platform
  └── Application
        └── Fork (production, staging, main, ...)
  • Platform — top-level owner (e.g., a company or team)
  • Application — a deployed Causet app within a platform
  • Fork — an isolated deployment environment

Each fork has its own:

  • PostgreSQL schema for projection tables and entity state
  • Active IR release (can be different across forks)
  • Kafka consumer group offset
  • Ledger event namespace (fork ID filters events in the ledger)

Schema naming

The tenant schema name is derived from the three-level hierarchy:

{platformId}_{applicationId}_{forkId}

Special characters in IDs are sanitized (lowercased, non-alphanumeric characters replaced with underscores). The resulting name must be 63 characters or fewer (PostgreSQL identifier limit).

Examples:

PlatformApplicationForkSchema
my-platformconcert-appproductionmy_platform_concert_app_production
acmeticketingmainacme_ticketing_main

Note: If the sanitized schema name exceeds 63 characters, deployment fails. Shorten the platform ID, application ID, or fork name.


Schema isolation mechanism

When the runtime or projection worker executes a query, it sets the PostgreSQL search_path to the tenant schema for the duration of the transaction:

BEGIN;
SET LOCAL search_path = my_platform_concert_app_production;
-- All subsequent queries resolve against this schema
SELECT * FROM user_following WHERE user_id = $1;
INSERT INTO user_following (user_id, artist_id, followed_at) VALUES ($1, $2, $3)
  ON CONFLICT (user_id, artist_id) DO UPDATE SET followed_at = EXCLUDED.followed_at;
COMMIT;

SET LOCAL applies only for the duration of the current transaction and reverts automatically. If a transaction is rolled back or a connection is returned to the pool, the search_path is cleared.

This prevents any cross-tenant data access as long as:

  1. The correct tenant schema is resolved from the authenticated JWT or API key
  2. The transaction is not shared across tenant boundaries

Authentication context

Clerk JWT

When a user authenticates through the control plane, Clerk issues a JWT containing:

{
  "sub": "user_clerk_id",
  "platform_id": "my-platform",
  "application_id": "concert-app",
  "fork_id": "production",
  "role": "admin"
}

The causet-saas-cloud validates this JWT on every request and resolves the tenant schema from the claims. The runtime and query service use the validated context to set search_path.

API keys

Server-to-server calls use API keys. Each API key is scoped to a platform and issued through the SaaS API:

curl -X POST "https://saas.yourdomain.com/v1/platforms/{platformId}/api-keys" \
  -H "Authorization: Bearer $CLERK_JWT" \
  -d '{ "name": "backend-service", "scopes": ["intents:write", "queries:read"] }'

The API key header is included with every request:

Authorization: ApiKey causet_key_abc123...

The runtime resolves the platform and validates the key’s scope before processing the intent.


Data residency

All tenants within a single Causet deployment share the same PostgreSQL instance. Isolation is at the schema level, not the database or instance level.

For deployments requiring database-level or instance-level isolation (regulatory requirements, contractual obligations), run separate Causet deployments with separate PostgreSQL instances.


Cross-tenant isolation guarantees

In standard operation:

  • No query in the runtime or projection worker references more than one tenant schema per transaction
  • There are no shared tables between tenants in the projection or causet databases
  • Lookups (LOOKUP_FIELD in preflight) resolve within the same tenant’s entity state — they cannot cross tenant boundaries

The one shared resource is the causet.ledger_events table in the causet database, which contains events from all tenants. Events are partitioned by fork_id and platform_id. The runtime always filters by the authenticated tenant’s context. Direct access to this table should be restricted by PostgreSQL row-level security if you require hard database-level isolation:

-- Example RLS policy (optional, for high-security deployments)
ALTER TABLE causet.ledger_events ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON causet.ledger_events
  USING (platform_id = current_setting('app.platform_id'));

Security checklist

  • API keys are rotated if a key is suspected compromised
  • Clerk JWT expiration is set appropriately (short-lived access tokens, refresh tokens for long sessions)
  • ECS task roles grant only the permissions required (S3 read for IR, Secrets Manager for credentials)
  • PostgreSQL connection credentials are separate per environment (staging key ≠ production key)
  • Production fork is not accessible from staging API keys
  • Fork names and schema names do not contain sensitive information (schema names appear in DB logs)