Skip to content

Multi Tenancy and Data

github-actions[bot] edited this page Aug 31, 2026 · 2 revisions

Multi Tenancy and Data

Tenant model

Dokene is designed as a multi-tenant SaaS from the beginning.

A tenant represents a business/customer account. Users are separate identities that can belong to one or more tenants through memberships and roles.

Do not model user == tenant.

Conceptually:

User
  │
  └── Membership ──> Tenant
                         │
                         ├── Customers
                         ├── Purchases
                         ├── Follow-ups
                         ├── Templates
                         ├── Integrations
                         └── Messages

Initial persistence strategy

The MVP uses:

  • one PostgreSQL database;
  • one shared application schema;
  • tenant_id on tenant-owned tables;
  • application-level tenant filters;
  • PostgreSQL Row Level Security (RLS) as defense in depth.

This offers a strong balance between operational simplicity and isolation for an early SaaS.

Why not schema-per-tenant initially?

Schema-per-tenant offers stronger logical separation, but introduces migration, connection-management, tooling, and operational complexity early.

Why not database-per-tenant initially?

Database-per-tenant offers strong isolation and can be appropriate for enterprise customers, but it increases provisioning, cost, migration, backup, monitoring, and operational complexity.

A future hybrid model is possible:

SMB tenants       → shared database/schema
Enterprise tenant → dedicated database when justified

Tenant context

The active tenant must be derived from authenticated membership/session state.

Never treat a request field such as:

tenantId=<uuid>

as trusted proof that the caller belongs to that tenant.

A request may select among memberships the user already has, but authorization must resolve that selection against trusted membership state.

PostgreSQL RLS

Tenant-owned tables enforce database-level isolation using PostgreSQL Row-Level Security (RLS).

Every tenant-scoped table enables and forces RLS with explicit CRUD policies:

ALTER TABLE dokene.customer ENABLE ROW LEVEL SECURITY;
ALTER TABLE dokene.customer FORCE ROW LEVEL SECURITY;

CREATE POLICY customer_select_policy
ON dokene.customer FOR SELECT TO dokene_runtime
USING (tenant_id = dokene.current_verified_tenant_id());

CREATE POLICY customer_insert_policy
ON dokene.customer FOR INSERT TO dokene_runtime
WITH CHECK (tenant_id = dokene.current_verified_tenant_id());

CREATE POLICY customer_update_policy
ON dokene.customer FOR UPDATE TO dokene_runtime
USING (tenant_id = dokene.current_verified_tenant_id())
WITH CHECK (tenant_id = dokene.current_verified_tenant_id());

CREATE POLICY customer_delete_policy
ON dokene.customer FOR DELETE TO dokene_runtime
USING (tenant_id = dokene.current_verified_tenant_id());

The application transports a short-lived, HMAC-signed tenant capability through two database settings. PostgreSQL verifies the signature, format, and expiry before returning the tenant identifier:

SELECT set_config('dokene.tenant_context', '<tenant-capability>', true),
       set_config('dokene.tenant_context_signature', '<hmac-signature>', true);

The settings are transport rather than authority: missing, altered, expired, or invalidly signed values evaluate to NULL and fail closed. The runtime role cannot read the signing key stored by the migration role.

See docs/architecture/tenant-isolation-rls-recipe.md and docs/adr/0003-postgresql-rls.md.

Data ownership

Examples of tenant-scoped entities:

  • customer;
  • purchase;
  • follow-up;
  • message;
  • message template;
  • integration configuration;
  • tenant settings;
  • audit records associated with tenant actions.

Global/platform-owned tables must be explicitly identified rather than accidentally omitting tenant_id.

Customer identity

A useful initial uniqueness rule may be:

(tenant_id, normalized_phone_number)

rather than globally unique phone number.

A customer can legitimately exist in multiple businesses/tenants.

Message templates

Tenant-authored message templates belong in PostgreSQL as versioned business data.

Do not make runtime filesystem .txt files the source of truth for tenant-specific templates in a horizontally deployed SaaS.

A template record should be capable of representing:

  • tenant;
  • name;
  • channel;
  • category;
  • language;
  • content/structured definition;
  • status;
  • provider template identifier where applicable;
  • version;
  • timestamps.

Repository resource files may define system defaults that are copied/provisioned into tenant data.

Message immutability and provenance

Once a message is approved/sent, keep enough immutable provenance to reconstruct what was actually sent.

Useful data includes:

  • rendered content;
  • template identifier/version;
  • recommendation/action source;
  • approval actor/time;
  • provider message identifier;
  • send/delivery/failure timestamps;
  • safe failure reason/status.

Do not rely on the current template text to reconstruct historical messages after a template changes.

Data minimization

Only collect data needed for product operation.

Classify data at least conceptually as:

PUBLIC
INTERNAL
CONFIDENTIAL
SECRET

Examples:

  • public documentation → PUBLIC;
  • internal business configuration → INTERNAL/CONFIDENTIAL;
  • customer contact/purchase data → CONFIDENTIAL;
  • API keys/tokens → SECRET.

Exports and deletion

Data export is a sensitive operation and should require explicit permission, audit logging, and rate limiting; re-authentication may be appropriate for high-risk exports.

Tenant deletion should have a defined lifecycle covering:

  • application rows;
  • integration secrets/references;
  • queued work;
  • exports;
  • backups/retention obligations;
  • audit requirements.

Backups

Backups should be encrypted, access-controlled, retention-aware, and periodically restore-tested. A backup that has never been restored successfully is not a proven recovery strategy.

Dokene Wiki


Canonical source: docs/wiki/ in stevdrey/dokene.

Durable decisions: docs/adr/.

Clone this wiki locally