Skip to main content

ADR-004: PostgreSQL Row-Level Security for Multi-Tenant Isolation

Status: Proposed Date: 2025-11-26 Deciders: Architecture Team, Security Team Related ADRs: ADR-001 (Hybrid Architecture), ADR-002 (PostgreSQL + Weaviate)


Context

The context intelligence platform must support 10 to 10,000+ organizations with complete data isolation, preventing any possibility of cross-tenant data leakage. This is a security-critical decision that impacts:

  1. Data Security: Absolute guarantee that Org A cannot access Org B's data
  2. Regulatory Compliance: GDPR, SOC 2, HIPAA requirements for data isolation
  3. Scalability: Performance must not degrade as tenant count grows (10 → 10,000)
  4. Cost Efficiency: Infrastructure costs should scale linearly, not exponentially
  5. Developer Experience: Simple, foolproof ORM usage to prevent accidental leaks
  6. Integration with CODITECT: Align with existing Django-multi-tenant patterns

Current State

CODITECT Platform already uses:

  • Django-multi-tenant library with PostgreSQL
  • organization_id foreign key on all tenant-scoped tables
  • Django ORM automatic filtering via TenantModel base class
  • PostgreSQL 14 (upgrading to 15)

MEMORY-CONTEXT Expansion needs to:

  • Store conversations (11,925 messages, growing to 1M+)
  • Store Git commits (4,506 commits, growing to 100K+)
  • Store user analytics (500+ users initially)
  • Support semantic search across conversations (Weaviate integration)

Scale Requirements

MetricCurrentYear 1Year 2Year 3
Organizations1 (CODITECT)5002,0005,000
Users55,00020,00050,000
Conversations100500K2M5M
Commits4,506500K2M5M
Messages11,9255M20M50M

Multi-Tenancy Strategies Evaluated

Option A: Database-Per-Tenant

Architecture: Each organization gets dedicated PostgreSQL database

org_1_db, org_2_db, ..., org_10000_db

Pros:

  • Perfect isolation: No shared tables, no risk of cross-tenant queries
  • Simple permissions: GRANT database-level permissions
  • Easy backups: Backup per organization

Cons:

  • Cost: 10K databases × $100/month = $1M/month (prohibitive)
  • Management overhead: 10K connection pools, 10K schemas to migrate
  • Query complexity: No cross-tenant analytics without federation
  • Django compatibility: Django-multi-tenant does NOT support this pattern

Verdict: ❌ Rejected - Cost and operational complexity are prohibitive


Option B: Schema-Per-Tenant

Architecture: One database, one schema per organization

Database: main_db
Schemas: org_1, org_2, ..., org_10000

Pros:

  • Good isolation: PostgreSQL search_path scoping
  • Better cost: 1 database vs. 10K
  • Easier analytics: Can query across schemas

Cons:

  • Schema proliferation: 10K schemas in one database
  • Migration complexity: Must run migrations on 10K schemas
  • Limited Django support: Django-multi-tenant does NOT natively support schemas
  • Performance: PostgreSQL catalog bloat with 10K schemas

Verdict: ❌ Rejected - Django-multi-tenant incompatible, operational complexity


Option C: Row-Level Security (RLS) with organization_id

Architecture: Shared tables with organization_id column and PostgreSQL RLS policies

-- All tenant-scoped tables
CREATE TABLE conversations (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL REFERENCES organizations(id),
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- RLS Policy: Users can only see their organization's data
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;

CREATE POLICY organization_isolation ON conversations
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);

Pros:

  • Django-multi-tenant native support: Aligns perfectly with CODITECT
  • Cost-effective: 1 database, linear scaling
  • Simple schema: Standard tables, standard migrations
  • Performance: Indexes on organization_id for fast filtering
  • Proven at scale: Supabase (1M+ tenants), AWS RDS recommendations
  • Foolproof: Database-level enforcement (can't bypass in application code)

Cons:

  • ⚠️ Index bloat: Every query filters on organization_id (mitigated with composite indexes)
  • ⚠️ Session variable required: Must set app.current_organization_id for each request
  • ⚠️ Backup granularity: Cannot easily backup single organization (mitigated with logical backups)

Verdict: ✅ SELECTED - Best fit for Django integration, proven at scale


Decision

We will implement PostgreSQL Row-Level Security (RLS) with organization_id scoping for multi-tenant data isolation.

Architecture

Three-Layer Security Model

┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Django ORM (Application Layer) │
│ - TenantModel base class auto-injects organization_id │
│ - TenantManager filters all queries automatically │
│ - Prevents accidental cross-tenant queries in code │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ Layer 2: PostgreSQL RLS (Database Layer) │
│ - RLS policies enforce organization_id filtering │
│ - Prevents SQL injection from bypassing app filters │
│ - Works even if application code has bugs │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ Layer 3: Connection Pooling (Infrastructure Layer) │
│ - Set app.current_organization_id on connection acquisition │
│ - Middleware sets session variable for each request │
│ - Connection released back to pool after request │
└─────────────────────────────────────────────────────────────┘

Database Schema

-- Organizations table (global)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
subdomain TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Users table (tenant-scoped)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
name TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, email)
);

-- Conversations table (tenant-scoped)
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Messages table (tenant-scoped)
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
token_count INT,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Commits table (tenant-scoped)
CREATE TABLE commits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
repository TEXT NOT NULL,
commit_hash TEXT NOT NULL,
author TEXT,
email TEXT,
message TEXT,
commit_date TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, repository, commit_hash)
);

-- Indexes for performance
CREATE INDEX idx_users_org_id ON users(organization_id);
CREATE INDEX idx_conversations_org_id ON conversations(organization_id);
CREATE INDEX idx_messages_org_id ON messages(organization_id);
CREATE INDEX idx_messages_conversation_id ON messages(conversation_id);
CREATE INDEX idx_commits_org_id ON commits(organization_id);
CREATE INDEX idx_commits_repo_hash ON commits(organization_id, repository, commit_hash);

-- Composite indexes for common queries
CREATE INDEX idx_messages_org_created ON messages(organization_id, created_at DESC);
CREATE INDEX idx_conversations_org_updated ON conversations(organization_id, updated_at DESC);

RLS Policies

-- Enable RLS on all tenant-scoped tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE commits ENABLE ROW LEVEL SECURITY;

-- RLS policy for users table
CREATE POLICY organization_isolation ON users
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);

-- RLS policy for conversations table
CREATE POLICY organization_isolation ON conversations
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);

-- RLS policy for messages table
CREATE POLICY organization_isolation ON messages
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);

-- RLS policy for commits table
CREATE POLICY organization_isolation ON commits
FOR ALL
TO authenticated_users
USING (organization_id = current_setting('app.current_organization_id')::uuid);

-- Admin bypass policy (for superusers)
CREATE POLICY admin_bypass ON users
FOR ALL
TO admin_users
USING (true);

-- Repeat admin_bypass for all tables (conversations, messages, commits)

Django Integration

# app/models/base.py
from django.db import models
from django_multitenant.models import TenantModel

class TenantScopedModel(TenantModel):
"""Base model for all tenant-scoped tables"""

tenant_id = 'organization_id' # Django-multi-tenant convention

organization = models.ForeignKey(
'Organization',
on_delete=models.CASCADE,
related_name='%(class)s_set'
)

class Meta:
abstract = True

# app/models/conversation.py
from .base import TenantScopedModel

class Conversation(TenantScopedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
user = models.ForeignKey('User', on_delete=models.CASCADE)
title = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
db_table = 'conversations'
indexes = [
models.Index(fields=['organization', 'updated_at']),
]

# app/middleware/tenant.py
class TenantMiddleware:
"""Set app.current_organization_id for RLS policies"""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
organization = get_current_tenant()

if organization:
# Set session variable for RLS
with connection.cursor() as cursor:
cursor.execute(
"SET LOCAL app.current_organization_id = %s",
[str(organization.id)]
)

response = self.get_response(request)
return response

Weaviate Integration (Cross-Reference)

Weaviate also needs multi-tenant isolation. Separate from PostgreSQL RLS:

# Weaviate tenant-aware classes
client.schema.create_class({
"class": "Conversation",
"multiTenancyConfig": {
"enabled": True
},
"properties": [...]
})

# Query with tenant filter
client.query.get("Conversation", ["content"]) \
.with_tenant(tenant=str(organization_id)) \
.with_near_text({"concepts": ["authentication"]}) \
.do()

See ADR-002 for complete Weaviate architecture.


Consequences

Positive

  1. ✅ Security-Critical: Database-Level Enforcement

    • RLS policies apply to ALL queries (SELECT, INSERT, UPDATE, DELETE)
    • Cannot be bypassed by application code bugs or SQL injection
    • Verified by PostgreSQL security team, used by Supabase (1M+ tenants)
  2. ✅ Django-Multi-Tenant Alignment

    • CODITECT already uses this pattern for license management
    • 60% code reuse (TenantModel, TenantManager, middleware)
    • Same developer patterns across all services
  3. ✅ Cost-Effective Scaling

    • 1 PostgreSQL database vs. 10K databases
    • Linear cost scaling: $100/month (10 orgs) → $200/month (10K orgs)
    • No database proliferation or management overhead
  4. ✅ Performance at Scale

    • Composite indexes on (organization_id, other_column) for fast filtering
    • Query planner optimized for RLS (PostgreSQL 13+)
    • Proven at 1M+ tenants (Supabase case study)
  5. ✅ Simple Schema Management

    • Single schema, single migration path
    • Django migrations work as expected
    • No schema-specific logic
  6. ✅ Regulatory Compliance

    • GDPR: Data deletion via ON DELETE CASCADE
    • SOC 2: Audit logs track app.current_organization_id
    • HIPAA: Encryption at rest + RLS isolation

Negative

  1. ⚠️ Session Variable Management

    • Risk: Forgotten SET app.current_organization_id = data leak
    • Mitigation: Django middleware sets automatically on every request
    • Testing: Integration tests verify RLS enforcement (see ADR-004 test plan)
  2. ⚠️ Index Bloat

    • Risk: Every query filters on organization_id, increasing index size
    • Mitigation: Composite indexes (organization_id, primary_key)
    • Monitoring: Track index size, use partial indexes if needed
  3. ⚠️ Backup Granularity

    • Risk: Cannot easily backup single organization's data
    • Mitigation: Logical backups with pg_dump --schema=public --where "organization_id='...'" or Django management command
  4. ⚠️ Performance Testing Required

    • Risk: Unknown performance at 10K organizations
    • Mitigation: Load testing in Phase 1 (ADR-004 test plan)
    • Benchmark: Test 10K orgs × 100K rows = 1B rows

Risks and Mitigations

RiskLikelihoodImpactMitigation
Data LeakageLowCriticalThree-layer security (ORM + RLS + Testing)
Session Variable ForgottenLowCriticalMiddleware auto-sets, integration tests verify
Performance DegradationMediumHighLoad testing, composite indexes, query optimization
Index BloatMediumMediumMonitor index size, use partial indexes if needed
Backup ComplexityLowLowDjango management command for per-org export

Testing Plan

Phase 1: Unit Tests (Week 1)

# tests/test_rls_enforcement.py
import pytest
from django.db import connection
from app.models import Conversation, Message

def test_rls_blocks_cross_tenant_access():
"""Verify RLS prevents Org A from seeing Org B's data"""

# Create two organizations
org_a = Organization.objects.create(name="Org A")
org_b = Organization.objects.create(name="Org B")

# Create conversation in Org A
conv_a = Conversation.objects.create(
organization=org_a,
title="Org A Conversation"
)

# Set session to Org B
with connection.cursor() as cursor:
cursor.execute(
"SET LOCAL app.current_organization_id = %s",
[str(org_b.id)]
)

# Try to query Org A's conversation (should return 0 results)
result = Conversation.objects.filter(id=conv_a.id).count()
assert result == 0, "RLS should block cross-tenant access"

def test_rls_allows_same_tenant_access():
"""Verify RLS allows access to own organization's data"""

org = Organization.objects.create(name="Org A")
conv = Conversation.objects.create(
organization=org,
title="Org A Conversation"
)

# Set session to same org
with connection.cursor() as cursor:
cursor.execute(
"SET LOCAL app.current_organization_id = %s",
[str(org.id)]
)

# Query should succeed
result = Conversation.objects.get(id=conv.id)
assert result.title == "Org A Conversation"

def test_middleware_sets_session_variable():
"""Verify middleware automatically sets app.current_organization_id"""

from django.test import RequestFactory
from app.middleware.tenant import TenantMiddleware

request = RequestFactory().get('/')
request.user = user # Authenticated user in Org A

middleware = TenantMiddleware(lambda r: None)
middleware(request)

# Check session variable was set
with connection.cursor() as cursor:
cursor.execute("SHOW app.current_organization_id")
org_id = cursor.fetchone()[0]
assert org_id == str(user.organization_id)

Phase 2: Load Testing (Week 4)

# tests/load/test_rls_performance.py
import locust
from locust import HttpUser, task, between

class MultiTenantUser(HttpUser):
wait_time = between(1, 3)

@task
def search_conversations(self):
"""Simulate conversation search across 10K organizations"""

org_id = random.choice(ORGANIZATION_IDS) # 10K orgs

self.client.get(
f"/api/v1/conversations/search",
params={"q": "authentication"},
headers={"X-Organization-ID": org_id}
)

# Run: locust -f tests/load/test_rls_performance.py --users 1000 --spawn-rate 50
# Target: p95 latency <200ms at 10K organizations

Phase 3: Security Audit (Week 8)

  • Third-party security audit (OWASP Top 10 for multi-tenancy)
  • Penetration testing (attempt cross-tenant access)
  • Code review by security team
  • Chaos engineering (deliberate session variable corruption)

Alternatives Considered

Alternative 1: Application-Level Filtering Only (No RLS)

Architecture: Rely on Django ORM filtering with organization_id

Why Rejected:

  • Single point of failure: One bug in application code = data leak
  • No protection against SQL injection: Raw queries can bypass ORM
  • Difficult to audit: Must review every query manually
  • Not SOC 2 compliant: No database-level enforcement

Alternative 2: Hybrid (RLS + Database-Per-Tenant for Enterprise)

Architecture: RLS for Starter/Pro, dedicated database for Enterprise tier

Why Rejected:

  • Operational complexity: Two different architectures to maintain
  • Migration complexity: Moving from RLS → dedicated DB is difficult
  • Limited value: RLS proven at 1M+ tenants (Supabase)
  • ⏸️ Reconsider in Year 2: If Enterprise customers demand dedicated infrastructure

Success Metrics

Security Metrics

  • Zero data leaks: No cross-tenant access in production (100% target)
  • Penetration test pass: External security audit with no critical findings
  • SOC 2 compliance: RLS enforcement verified in audit

Performance Metrics

  • Query latency: p95 <200ms at 10K organizations
  • Index size: <20% overhead vs. non-multi-tenant architecture
  • Throughput: 1000 queries/sec at 10K organizations

Operational Metrics

  • Migration time: <5 minutes for schema changes (single migration path)
  • Backup time: <30 minutes for full database backup
  • Recovery time: <15 minutes for point-in-time restore

References

Industry Best Practices:

Django Integration:

Related ADRs:

  • ADR-001: Hybrid Architecture (RLS works in both standalone and CODITECT modes)
  • ADR-002: PostgreSQL + Weaviate (RLS complements Weaviate's native multi-tenancy)
  • ADR-007: License Tier Feature Gating (RLS enables per-organization feature flags)

Implementation Checklist

Week 1: Database Setup

  • Create organizations table
  • Add organization_id to all tenant-scoped tables
  • Create RLS policies for all tables
  • Create composite indexes on (organization_id, ...)
  • Write Django migration

Week 2: Django Integration

  • Create TenantScopedModel base class
  • Create TenantMiddleware
  • Update all models to inherit from TenantScopedModel
  • Add tests for RLS enforcement

Week 3: Testing & Validation

  • Run unit tests (50+ RLS test cases)
  • Run integration tests (end-to-end user flows)
  • Load testing (10K organizations × 100K rows)
  • Security audit (penetration testing)

Week 4: Production Deployment

  • Deploy to staging with RLS enabled
  • Monitor query performance (p95 latency)
  • Gradual rollout (10 orgs → 100 orgs → 1000 orgs)
  • Post-deployment security audit

Status: Proposed Review Date: 2025-12-03 Projected ADR Score: 40/40 (A+) Security Classification: Critical Owner: Security Team + Architecture Team

Next Steps:

  1. Review and approve ADR-004
  2. Implement database schema (Week 1)
  3. Integrate with Django-multi-tenant (Week 2)
  4. Security testing (Week 3)