Skip to content

Multi-Tenancy Architecture

DELPHOS is a multi-tenant platform where each clinic or organization operates in complete data isolation. A tenant is identified by an app_id (UUID) that is resolved automatically from the API key sent in the x-api-key header — callers never need to specify a tenant manually.

How Isolation Works

Every API request passes through the following pipeline before any data is read or written:

Request + x-api-key header
|
v
1. Authentication ─── resolve app_id from key hash
|
v
2. Tenant Middleware ─── SET LOCAL app.current_app_id = '{app_id}'
|
v
3. PostgreSQL RLS ─── WHERE app_id = current_setting('app.current_app_id')
|
v
4. Response ─── contains only this tenant's data

Because the session variable is set with SET LOCAL, it is transaction-scoped and automatically cleared when the transaction ends. There is no risk of a leaked context carrying over to a subsequent request.


Tenant Model

Each tenant is represented by an App record with the following key fields:

FieldTypeDescription
idUUID (PK)Unique tenant identifier (app_id)
slugtextURL-safe short name (e.g. clinica-saude)
api_key_hashtextSHA-256 hash of the master key
rate_limitsJSONBPer-tenant throttle config
is_activebooleanDisabled tenants are rejected at auth

Default Rate Limits

{
"per_minute": 60,
"per_hour": 1000,
"per_day": 10000
}

Enterprise plans can override these values to higher or unlimited quotas.


API Key Model

Each App can have multiple API keys, each with its own scope and lifecycle:

FieldTypeDescription
idUUID (PK)Key identifier
app_idUUID (FK)Owning tenant
key_prefixchar(16)Visible prefix for identification
environmentenumproduction, staging, or development
permissionsJSONBGranular capability flags
expires_attimestamptzOptional expiration
revoked_attimestamptzNull until explicitly revoked
last_used_attimestamptzUpdated on each request
usage_countbigintLifetime request counter

Permission Flags

The permissions JSONB object controls what the key can do:

{
"can_read": true,
"can_write": true,
"can_delete": false,
"can_admin": false
}

A staging key might enable reads and writes but disable deletes and admin operations. A CI/CD key might be read-only. This lets you follow the principle of least privilege for every integration point.


Row-Level Security Policies

RLS is enabled on every tenant-scoped table. The policy pattern is consistent across the entire schema:

CREATE POLICY documents_isolation ON documents
FOR ALL TO PUBLIC
USING (
app_id = current_setting('app.current_app_id', TRUE)::UUID
);

The USING clause filters rows on read, update, and delete — a tenant can never see or modify another tenant’s records.

Tables Covered

RLS policies are applied to all data tables, including:

  • apps and api_keys
  • documents and knowledge_collections
  • patients and consultations
  • prescriptions
  • usage_tracking
  • All future tenant-scoped tables follow the same pattern

Tenant Context Middleware

After authentication resolves the app_id, a dedicated middleware sets the PostgreSQL session variables that RLS policies depend on:

TenantContextMiddleware
|
+-- SET LOCAL app.current_app_id = '{app_id}'
+-- SET LOCAL app.current_tenant_slug = '{slug}'
|
v
Request proceeds with tenant context active

Key behaviors:

  • Transaction-local scopeSET LOCAL is scoped to the current transaction and is automatically cleared on commit or rollback.
  • Cross-tenant detection — If a request somehow references a different tenant’s app_id, the event is logged to the security_audit_log at CRITICAL severity.
  • Non-blocking audit — Security audit logging is asynchronous so it never adds latency to the request path.

Repository Pattern (Double Protection)

Every data repository accepts app_id in its constructor and calls SET LOCAL before executing any query:

class DoctorRepository:
def __init__(self, app_id: UUID):
self.app_id = app_id
async def find_by_crm(self, crm: str) -> Doctor | None:
async with self.pool.acquire() as conn:
# app_id is a UUID validated at the authentication layer
await conn.execute(
f"SET LOCAL app.current_app_id = '{self.app_id}'"
)
return await conn.fetchrow(
"SELECT * FROM doctors WHERE crm = $1", crm
)

This means tenant isolation is enforced at two independent layers:

  1. Middleware — sets context before the handler runs
  2. Repository — re-sets context before each query executes

Even if a code path bypasses the middleware (e.g., a background job), the repository still enforces the correct tenant scope.


Tenant-Scoped Knowledge Bases

Each tenant has an independent set of knowledge base collections ( used by the DELPHOS intelligence layer):

  • Collection names are derived from the tenant slug: {slug}_medical_knowledge
  • A unique constraint UNIQUE(app_id, name) prevents naming collisions across tenants.
  • Indices and search results are all scoped — a query from Tenant A never returns documents belonging to Tenant B.
Tenant "clinica-saude"
└── clinica-saude_medical_knowledge
├── 12,400 embedded documents
└── HNSW index (independent)
Tenant "hospital-central"
└── hospital-central_medical_knowledge
├── 38,200 embedded documents
└── HNSW index (independent)

Quota and Usage Tracking

Rate limits are stored per tenant in the apps.rate_limits JSONB column and enforced before the request reaches the handler:

WindowDefault LimitEnterprise
Per minute60Custom
Per hour1,000Custom
Per day10,000Unlimited

Usage is tracked per tenant, per endpoint, and per time window. The usage records themselves are protected by RLS — a tenant can query its own consumption but never see another tenant’s traffic patterns.


Cross-Tenant Protection

DELPHOS implements defense-in-depth against cross-tenant access:

LayerMechanism
API key bindingEach key is cryptographically bound to one tenant. Presenting a key from Tenant A will always resolve to Tenant A’s app_id.
RLS policiesPostgreSQL enforces row filtering at the storage engine level, below application code.
Middleware contextSET LOCAL ensures the session variable cannot leak across requests.
Audit loggingAny CROSS_TENANT_ATTEMPT event is logged at CRITICAL severity with full request context.
Repository guardsDouble-check layer that re-sets tenant context per query.

Provisioning a New Tenant

The high-level flow for creating a new tenant:

1. Create App record (slug, rate_limits, is_active=true)
|
v
2. Generate API key → link to App via app_id FK
|
v
3. Return plaintext key to caller (returned exactly once)
|
v
4. Store only the key hash — plaintext is never persisted

Example: Using the API Key

Once provisioned, the tenant authenticates by passing the key in every request:

Terminal window
curl -X GET "https://your-instance.delphos.app/v1/patients" \
-H "x-api-key: delphos_clinica-saude_prod_a1b2c3d4e5f6g7h8" \
-H "Content-Type: application/json"

The x-api-key header is the only credential required. DELPHOS resolves the tenant, sets the RLS context, and enforces rate limits — all transparently.


Summary

ConcernHow DELPHOS Handles It
IdentityUUID app_id resolved from API key hash
Data isolationPostgreSQL RLS on every tenant-scoped table
Context propagationSET LOCAL session variable (transaction-scoped)
Write protectionWITH CHECK policies prevent cross-tenant inserts
Knowledge basesPer-tenant collections with independent indices
Rate limitingJSONB config per tenant with per-minute/hour/day windows
Audit trailCross-tenant attempts logged at CRITICAL severity
Key managementMultiple keys per tenant, each with scoped permissions

Next Steps